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 <cursoragent@cursor.com>
This commit is contained in:
2026-05-13 00:44:13 +12:00
parent cad0aa7e59
commit c64dd736f2
4 changed files with 255 additions and 76 deletions

View File

@@ -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}} {"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}}

View File

@@ -167,6 +167,107 @@ async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, nam
pass pass
async def send_identify_to_device(dev_id: str) -> tuple[int, str]:
"""
Send the same identify blink as ``POST /devices/<id>/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("") @controller.get("")
async def list_devices(request): async def list_devices(request):
"""List all devices (includes ``connected`` for live Wi-Fi WebSocket presence).""" """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 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``. / ESP-NOW. No ``save``. After ``IDENTIFY_OFF_DELAY_S``, a background task selects ``off``.
""" """
dev = devices.read(id) status, err = await send_identify_to_device(id)
if not dev: if status == 200:
return json.dumps({"error": "Device not found"}), 404, { return json.dumps({"message": "Identify sent"}), 200, {
"Content-Type": "application/json", "Content-Type": "application/json",
} }
sender = get_current_sender() return json.dumps({"error": err}), status, {"Content-Type": "application/json"}
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",
}
@controller.post("/<id>/brightness") @controller.post("/<id>/brightness")

View File

@@ -1,4 +1,5 @@
from microdot import Microdot from microdot import Microdot
import asyncio
from models.group import Group from models.group import Group
from models.device import Device from models.device import Device
from models.transport import get_current_sender 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 [] mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
sent = 0 sent = 0
errors = [] errors = []
msg = json.dumps(
{"v": "1", "device_config": dc, "save": True}, separators=(",", ":")
)
tasks = []
meta_macs = []
for mac in mac_list: for mac in mac_list:
m = str(mac).strip().lower().replace(":", "").replace("-", "") m = str(mac).strip().lower().replace(":", "").replace("-", "")
if len(m) != 12: if len(m) != 12:
@@ -130,14 +136,17 @@ async def push_group_driver_config(request, id):
if not ip: if not ip:
errors.append({"mac": m, "error": "no IP"}) errors.append({"mac": m, "error": "no IP"})
continue continue
msg = json.dumps( tasks.append(send_json_line_to_ip(ip, msg))
{"v": "1", "device_config": dc, "save": True}, separators=(",", ":") meta_macs.append(m)
) if tasks:
ok = await send_json_line_to_ip(ip, msg) results = await asyncio.gather(*tasks, return_exceptions=True)
if ok: for m, r in zip(meta_macs, results):
sent += 1 if r is True:
else: sent += 1
errors.append({"mac": m, "error": "driver not connected"}) elif isinstance(r, Exception):
errors.append({"mac": m, "error": str(r)})
else:
errors.append({"mac": m, "error": "driver not connected"})
return json.dumps( return json.dumps(
{"message": "driver-config sent", "sent": sent, "errors": errors} {"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 [] mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
sent = 0 sent = 0
errors = [] errors = []
for mac in mac_list: sender = get_current_sender()
m = str(mac).strip().lower().replace(":", "").replace("-", "")
if len(m) != 12: async def _push_brightness_one(m: str, dev: dict) -> tuple[str, bool, str | None]:
continue
dev = devices.read(m)
if not dev:
errors.append({"mac": m, "error": "not in registry"})
continue
b_val = effective_brightness_for_mac( b_val = effective_brightness_for_mac(
_pi_settings, _pi_settings,
groups, groups,
@@ -181,24 +185,79 @@ async def push_group_output_brightness(request, id):
if transport == "wifi": if transport == "wifi":
ip = normalize_tcp_peer_ip(str(dev.get("address") or "")) ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
if not ip: if not ip:
errors.append({"mac": m, "error": "no IP"}) return m, False, "no IP"
continue
ok = await send_json_line_to_ip(ip, msg) 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: if ok:
sent += 1 sent += 1
else: elif err:
errors.append({"mac": m, "error": "driver not connected"}) errors.append({"mac": m, "error": err})
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)})
return json.dumps( return json.dumps(
{"message": "brightness sent", "sent": sent, "errors": errors} {"message": "brightness sent", "sent": sent, "errors": errors}
), 200, {"Content-Type": "application/json"} ), 200, {"Content-Type": "application/json"}
@controller.post("/<id>/identify")
async def identify_group_devices(request, id):
"""
Run the same identify blink as ``POST /devices/<id>/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"}

View File

@@ -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'); const delBtn = document.createElement('button');
delBtn.className = 'btn btn-danger btn-small'; delBtn.className = 'btn btn-danger btn-small';
delBtn.textContent = 'Delete'; delBtn.textContent = 'Delete';
@@ -348,11 +358,40 @@ function renderGroupsList(groups) {
row.appendChild(editBtn); row.appendChild(editBtn);
row.appendChild(brightBtn); row.appendChild(brightBtn);
row.appendChild(applyBtn); row.appendChild(applyBtn);
row.appendChild(identifyBtn);
row.appendChild(delBtn); row.appendChild(delBtn);
container.appendChild(row); 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', () => { document.addEventListener('DOMContentLoaded', () => {
const groupsBtn = document.getElementById('groups-btn'); const groupsBtn = document.getElementById('groups-btn');
const groupsModal = document.getElementById('groups-modal'); 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 createHandler = async () => {
const name = newNameInput && newNameInput.value.trim(); const name = newNameInput && newNameInput.value.trim();
if (!name) return; if (!name) return;
@@ -449,4 +498,15 @@ document.addEventListener('DOMContentLoaded', () => {
if (editCloseBtn && editModal) { if (editCloseBtn && editModal) {
editCloseBtn.addEventListener('click', () => editModal.classList.remove('active')); 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);
}
};
}); });