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:
@@ -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}}
|
||||||
@@ -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, {
|
|
||||||
"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, {
|
return json.dumps({"message": "Identify sent"}), 200, {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
return json.dumps({"error": err}), status, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
@controller.post("/<id>/brightness")
|
@controller.post("/<id>/brightness")
|
||||||
|
|||||||
@@ -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,12 +136,15 @@ 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):
|
||||||
|
if r is True:
|
||||||
sent += 1
|
sent += 1
|
||||||
|
elif isinstance(r, Exception):
|
||||||
|
errors.append({"mac": m, "error": str(r)})
|
||||||
else:
|
else:
|
||||||
errors.append({"mac": m, "error": "driver not connected"})
|
errors.append({"mac": m, "error": "driver not connected"})
|
||||||
|
|
||||||
@@ -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)
|
||||||
if ok:
|
return m, bool(ok), None if ok else "driver not connected"
|
||||||
sent += 1
|
|
||||||
else:
|
|
||||||
errors.append({"mac": m, "error": "driver not connected"})
|
|
||||||
else:
|
|
||||||
sender = get_current_sender()
|
|
||||||
if not sender:
|
if not sender:
|
||||||
errors.append({"mac": m, "error": "transport not configured"})
|
return m, False, "transport not configured"
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
await sender.send(msg, addr=m)
|
await sender.send(msg, addr=m)
|
||||||
sent += 1
|
return m, True, None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append({"mac": m, "error": str(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
|
||||||
|
elif err:
|
||||||
|
errors.append({"mac": m, "error": err})
|
||||||
|
|
||||||
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"}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user