From c64dd736f205d095e2fd699cb5642e81eb35a1c4 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Wed, 13 May 2026 00:44:13 +1200 Subject: [PATCH] feat(api): parallel group sends and batch identify - asyncio.gather for group brightness and driver-config Wi-Fi pushes - Batch identify envelope for group members Co-authored-by: Cursor --- db/group.json | 2 +- src/controllers/device.py | 150 ++++++++++++++++++++++++++------------ src/controllers/group.py | 119 ++++++++++++++++++++++-------- src/static/groups.js | 60 +++++++++++++++ 4 files changed, 255 insertions(+), 76 deletions(-) diff --git a/db/group.json b/db/group.json index 2ca8913..fb867f8 100644 --- a/db/group.json +++ b/db/group.json @@ -1 +1 @@ -{"1": {"name": "Main Group", "devices": ["188b0e1560a8"], "wifi_driver_display_name": "desk", "wifi_driver_num_leds": 59, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "output_brightness": 255}} \ No newline at end of file +{"1": {"name": "group1", "devices": ["e8f60a16fb00", "e8f60a170794"], "wifi_driver_display_name": "desk", "wifi_driver_num_leds": 59, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "output_brightness": 255}, "2": {"name": "group2", "devices": ["188b0e1560a8"], "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0}} \ No newline at end of file diff --git a/src/controllers/device.py b/src/controllers/device.py index 8ba3d0c..98efe03 100644 --- a/src/controllers/device.py +++ b/src/controllers/device.py @@ -167,6 +167,107 @@ async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, nam pass +async def send_identify_to_device(dev_id: str) -> tuple[int, str]: + """ + Send the same identify blink as ``POST /devices//identify``. + + Returns ``(http_status, "")`` on success, or ``(status, error_message)`` on failure + (status matches the single-device route). + """ + dev = devices.read(dev_id) + if not dev: + return 404, "Device not found" + sender = get_current_sender() + if not sender: + return 503, "Transport not configured" + name = str(dev.get("name") or "").strip() + if not name: + return 400, "Device must have a name to identify" + + transport = dev.get("transport") or "espnow" + wifi_ip = None + if transport == "wifi": + wifi_ip = dev.get("address") + if not wifi_ip: + return 400, "Device has no IP address" + + try: + msg = _compact_v1_json( + presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)}, + select={name: [_IDENTIFY_PRESET_KEY]}, + ) + if transport == "wifi": + ok = await send_json_line_to_ip(wifi_ip, msg) + if not ok: + return 503, "Wi-Fi driver not connected" + else: + await sender.send(msg, addr=dev_id) + + asyncio.create_task( + _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name) + ) + except Exception as e: + return 503, str(e) + return 200, "" + + +async def send_identify_to_group_devices(macs: list[str]) -> tuple[int, list[dict]]: + """ + Identify every listed registry MAC in one delivery round: merged ``select`` and a single + ESP-NOW split envelope when multiple peers share the serial bridge (avoids per-device + ``SerialSender`` lock serialisation). Wi-Fi peers are sent in parallel as in + ``deliver_json_messages``. + """ + from util.driver_delivery import deliver_json_messages + + errors: list[dict] = [] + sender = get_current_sender() + if not sender: + return 0, [{"mac": "*", "error": "Transport not configured"}] + + merged_select: dict[str, list[str]] = {} + valid_macs: list[str] = [] + for dev_id in macs: + dev = devices.read(dev_id) + if not dev: + errors.append({"mac": dev_id, "error": "Device not found"}) + continue + name = str(dev.get("name") or "").strip() + if not name: + errors.append({"mac": dev_id, "error": "Device must have a name to identify"}) + continue + transport = (dev.get("transport") or "espnow").strip().lower() + if transport == "wifi": + if not dev.get("address"): + errors.append({"mac": dev_id, "error": "Device has no IP address"}) + continue + merged_select[name] = [_IDENTIFY_PRESET_KEY] + valid_macs.append(dev_id) + + if not merged_select: + return 0, errors + + try: + msg = _compact_v1_json( + presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)}, + select=merged_select, + ) + await deliver_json_messages(sender, [msg], valid_macs, devices, delay_s=0) + except Exception as e: + return 0, errors + [{"mac": "*", "error": str(e)}] + + for dev_id in valid_macs: + dev = devices.read(dev_id) or {} + name = str(dev.get("name") or "").strip() + transport = (dev.get("transport") or "espnow").strip().lower() + wifi_ip = dev.get("address") if transport == "wifi" else None + asyncio.create_task( + _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name) + ) + + return len(valid_macs), errors + + @controller.get("") async def list_devices(request): """List all devices (includes ``connected`` for live Wi-Fi WebSocket presence).""" @@ -341,53 +442,12 @@ async def identify_device(request, id): this device name — same combined shape as profile sends the driver already accepts over TCP / ESP-NOW. No ``save``. After ``IDENTIFY_OFF_DELAY_S``, a background task selects ``off``. """ - dev = devices.read(id) - if not dev: - return json.dumps({"error": "Device not found"}), 404, { + status, err = await send_identify_to_device(id) + if status == 200: + return json.dumps({"message": "Identify sent"}), 200, { "Content-Type": "application/json", } - sender = get_current_sender() - if not sender: - return json.dumps({"error": "Transport not configured"}), 503, { - "Content-Type": "application/json", - } - name = str(dev.get("name") or "").strip() - if not name: - return json.dumps({"error": "Device must have a name to identify"}), 400, { - "Content-Type": "application/json", - } - - transport = dev.get("transport") or "espnow" - wifi_ip = None - if transport == "wifi": - wifi_ip = dev.get("address") - if not wifi_ip: - return json.dumps({"error": "Device has no IP address"}), 400, { - "Content-Type": "application/json", - } - - try: - msg = _compact_v1_json( - presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)}, - select={name: [_IDENTIFY_PRESET_KEY]}, - ) - if transport == "wifi": - ok = await send_json_line_to_ip(wifi_ip, msg) - if not ok: - return json.dumps({"error": "Wi-Fi driver not connected"}), 503, { - "Content-Type": "application/json", - } - else: - await sender.send(msg, addr=id) - - asyncio.create_task( - _identify_send_off_after_delay(sender, transport, wifi_ip, id, name) - ) - except Exception as e: - return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"} - return json.dumps({"message": "Identify sent"}), 200, { - "Content-Type": "application/json", - } + return json.dumps({"error": err}), status, {"Content-Type": "application/json"} @controller.post("//brightness") diff --git a/src/controllers/group.py b/src/controllers/group.py index 835918c..e18b94f 100644 --- a/src/controllers/group.py +++ b/src/controllers/group.py @@ -1,4 +1,5 @@ from microdot import Microdot +import asyncio from models.group import Group from models.device import Device from models.transport import get_current_sender @@ -116,6 +117,11 @@ async def push_group_driver_config(request, id): mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else [] sent = 0 errors = [] + msg = json.dumps( + {"v": "1", "device_config": dc, "save": True}, separators=(",", ":") + ) + tasks = [] + meta_macs = [] for mac in mac_list: m = str(mac).strip().lower().replace(":", "").replace("-", "") if len(m) != 12: @@ -130,14 +136,17 @@ async def push_group_driver_config(request, id): if not ip: errors.append({"mac": m, "error": "no IP"}) continue - msg = json.dumps( - {"v": "1", "device_config": dc, "save": True}, separators=(",", ":") - ) - ok = await send_json_line_to_ip(ip, msg) - if ok: - sent += 1 - else: - errors.append({"mac": m, "error": "driver not connected"}) + tasks.append(send_json_line_to_ip(ip, msg)) + meta_macs.append(m) + if tasks: + results = await asyncio.gather(*tasks, return_exceptions=True) + for m, r in zip(meta_macs, results): + if r is True: + sent += 1 + elif isinstance(r, Exception): + errors.append({"mac": m, "error": str(r)}) + else: + errors.append({"mac": m, "error": "driver not connected"}) return json.dumps( {"message": "driver-config sent", "sent": sent, "errors": errors} @@ -161,14 +170,9 @@ async def push_group_output_brightness(request, id): mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else [] sent = 0 errors = [] - for mac in mac_list: - m = str(mac).strip().lower().replace(":", "").replace("-", "") - if len(m) != 12: - continue - dev = devices.read(m) - if not dev: - errors.append({"mac": m, "error": "not in registry"}) - continue + sender = get_current_sender() + + async def _push_brightness_one(m: str, dev: dict) -> tuple[str, bool, str | None]: b_val = effective_brightness_for_mac( _pi_settings, groups, @@ -181,24 +185,79 @@ async def push_group_output_brightness(request, id): if transport == "wifi": ip = normalize_tcp_peer_ip(str(dev.get("address") or "")) if not ip: - errors.append({"mac": m, "error": "no IP"}) - continue + return m, False, "no IP" ok = await send_json_line_to_ip(ip, msg) + return m, bool(ok), None if ok else "driver not connected" + if not sender: + return m, False, "transport not configured" + try: + await sender.send(msg, addr=m) + return m, True, None + except Exception as e: + return m, False, str(e) + + tasks: list = [] + for mac in mac_list: + m = str(mac).strip().lower().replace(":", "").replace("-", "") + if len(m) != 12: + continue + dev = devices.read(m) + if not dev: + errors.append({"mac": m, "error": "not in registry"}) + continue + tasks.append(_push_brightness_one(m, dev)) + + if tasks: + results = await asyncio.gather(*tasks, return_exceptions=True) + for r in results: + if isinstance(r, Exception): + errors.append({"mac": "*", "error": str(r)}) + continue + m, ok, err = r if ok: sent += 1 - else: - errors.append({"mac": m, "error": "driver not connected"}) - else: - sender = get_current_sender() - if not sender: - errors.append({"mac": m, "error": "transport not configured"}) - continue - try: - await sender.send(msg, addr=m) - sent += 1 - except Exception as e: - errors.append({"mac": m, "error": str(e)}) + elif err: + errors.append({"mac": m, "error": err}) return json.dumps( {"message": "brightness sent", "sent": sent, "errors": errors} ), 200, {"Content-Type": "application/json"} + + +@controller.post("//identify") +async def identify_group_devices(request, id): + """ + Run the same identify blink as ``POST /devices//identify`` for every registry member + in parallel so all drivers in the group blink together. + """ + _ = request + gdoc = groups.read(id) + if not gdoc: + return json.dumps({"error": "Group not found"}), 404, {"Content-Type": "application/json"} + + mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else [] + if not mac_list: + return json.dumps({"error": "Group has no devices"}), 400, {"Content-Type": "application/json"} + + from controllers.device import send_identify_to_group_devices + + normalized: list[str] = [] + errors: list[dict] = [] + for mac in mac_list: + m = str(mac).strip().lower().replace(":", "").replace("-", "") + if len(m) != 12: + errors.append({"mac": str(mac), "error": "invalid MAC"}) + continue + normalized.append(m) + + if not normalized: + return json.dumps( + {"message": "identify group done", "sent": 0, "errors": errors} + ), 200, {"Content-Type": "application/json"} + + sent, batch_errors = await send_identify_to_group_devices(normalized) + errors.extend(batch_errors) + + return json.dumps( + {"message": "identify group done", "sent": sent, "errors": errors} + ), 200, {"Content-Type": "application/json"} diff --git a/src/static/groups.js b/src/static/groups.js index fc30671..421288f 100644 --- a/src/static/groups.js +++ b/src/static/groups.js @@ -326,6 +326,16 @@ function renderGroupsList(groups) { } }); + const identifyBtn = document.createElement('button'); + identifyBtn.className = 'btn btn-secondary btn-small'; + identifyBtn.type = 'button'; + identifyBtn.textContent = 'Identify'; + identifyBtn.title = + 'Identify all devices in this group at once (red blink at 10 Hz)'; + identifyBtn.addEventListener('click', async () => { + await identifyGroupById(gid); + }); + const delBtn = document.createElement('button'); delBtn.className = 'btn btn-danger btn-small'; delBtn.textContent = 'Delete'; @@ -348,11 +358,40 @@ function renderGroupsList(groups) { row.appendChild(editBtn); row.appendChild(brightBtn); row.appendChild(applyBtn); + row.appendChild(identifyBtn); row.appendChild(delBtn); container.appendChild(row); }); } +async function identifyGroupById(gid) { + if (!gid) return; + try { + const res = await fetch(`/groups/${encodeURIComponent(gid)}/identify`, { + method: 'POST', + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + alert(data.error || 'Identify failed'); + return; + } + const n = typeof data.sent === 'number' ? data.sent : 0; + const errs = Array.isArray(data.errors) ? data.errors : []; + const failed = errs.filter((e) => e && e.error).length; + let msg = n ? `Identify sent to ${n} device(s).` : 'No devices received identify.'; + if (failed) { + msg += ` ${failed} failed — see console for details.`; + console.warn('Group identify errors', errs); + } + alert(msg); + } catch (e) { + console.error(e); + alert('Identify failed'); + } +} + document.addEventListener('DOMContentLoaded', () => { const groupsBtn = document.getElementById('groups-btn'); const groupsModal = document.getElementById('groups-modal'); @@ -381,6 +420,16 @@ document.addEventListener('DOMContentLoaded', () => { }); } + const editIdentifyBtn = document.getElementById('edit-group-identify-btn'); + if (editIdentifyBtn) { + editIdentifyBtn.addEventListener('click', async () => { + const idInput = document.getElementById('edit-group-id'); + const gid = idInput && idInput.value; + if (!gid) return; + await identifyGroupById(gid); + }); + } + const createHandler = async () => { const name = newNameInput && newNameInput.value.trim(); if (!name) return; @@ -449,4 +498,15 @@ document.addEventListener('DOMContentLoaded', () => { if (editCloseBtn && editModal) { editCloseBtn.addEventListener('click', () => editModal.classList.remove('active')); } + + window.openDeviceGroupsModal = async () => { + const gm = document.getElementById('groups-modal'); + if (!gm) return; + gm.classList.add('active'); + try { + await loadGroupsModal(); + } catch (e) { + console.error('openDeviceGroupsModal', e); + } + }; });