feat(api): tcp driver registry, identify, preset push delivery

- Track Wi-Fi TCP clients, liveness pings, disconnect broadcast, bind errors via gather\n- Device list/get include connected; POST identify with __identify preset\n- Presets push/send delivery helpers; bump led-driver hello type

Made-with: Cursor
This commit is contained in:
pi
2026-04-06 00:21:57 +12:00
parent e6b5bf2cf1
commit f8eba0ee7e
15 changed files with 1052 additions and 108 deletions

View File

@@ -5,29 +5,104 @@ from models.device import (
validate_device_transport,
validate_device_type,
)
from models.transport import get_current_sender
from models.tcp_clients import (
normalize_tcp_peer_ip,
send_json_line_to_ip,
tcp_client_connected,
)
from util.espnow_message import build_message
import asyncio
import json
# Ephemeral driver preset name (never written to Pi preset store; ``save`` not set on wire).
_IDENTIFY_PRESET_KEY = "__identify"
# Short-key payload: 10 Hz full cycle = 50 ms on + 50 ms off (driver ``blink`` toggles each ``d`` ms).
_IDENTIFY_DRIVER_PRESET = {
"p": "blink",
"c": ["#ff0000"],
"d": 50,
"b": 128,
"a": True,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
}
def _compact_v1_json(*, presets=None, select=None, save=False):
"""Single-line v1 object; compact so serial/ESP-NOW stays small."""
body = {"v": "1"}
if presets is not None:
body["presets"] = presets
if save:
body["save"] = True
if select is not None:
body["select"] = select
return json.dumps(body, separators=(",", ":"))
# Seconds after identify blink before selecting built-in ``off`` (tests may monkeypatch).
IDENTIFY_OFF_DELAY_S = 2.0
controller = Microdot()
devices = Device()
def _device_live_connected(dev_dict):
"""
Wi-Fi: whether a TCP client is registered for this device's address (IP).
ESP-NOW: None (no TCP session on the Pi for that transport).
"""
tr = (dev_dict.get("transport") or "espnow").strip().lower()
if tr != "wifi":
return None
ip = normalize_tcp_peer_ip(dev_dict.get("address") or "")
if not ip:
return False
return tcp_client_connected(ip)
def _device_json_with_live_status(dev_dict):
row = dict(dev_dict)
row["connected"] = _device_live_connected(dev_dict)
return row
async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name):
try:
await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
off_msg = build_message(select={name: ["off"]})
if transport == "wifi":
await send_json_line_to_ip(wifi_ip, off_msg)
else:
await sender.send(off_msg, addr=dev_id)
except Exception:
pass
@controller.get("")
async def list_devices(request):
"""List all devices."""
"""List all devices (includes ``connected`` for live Wi-Fi TCP presence)."""
devices_data = {}
for dev_id in devices.list():
d = devices.read(dev_id)
if d:
devices_data[dev_id] = d
devices_data[dev_id] = _device_json_with_live_status(d)
return json.dumps(devices_data), 200, {"Content-Type": "application/json"}
@controller.get("/<id>")
async def get_device(request, id):
"""Get a device by ID."""
"""Get a device by ID (includes ``connected`` for live Wi-Fi TCP presence)."""
dev = devices.read(id)
if dev:
return json.dumps(dev), 200, {"Content-Type": "application/json"}
return json.dumps(_device_json_with_live_status(dev)), 200, {
"Content-Type": "application/json",
}
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
@@ -91,6 +166,7 @@ async def update_device(request, id):
data = dict(raw)
data.pop("id", None)
data.pop("addresses", None)
data.pop("connected", None)
if "name" in data:
n = (data.get("name") or "").strip()
if not n:
@@ -127,3 +203,59 @@ async def delete_device(request, id):
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
@controller.post("/<id>/identify")
async def identify_device(request, id):
"""
One v1 JSON object: ``presets.__identify`` (``d``=50 ms → 10 Hz blink) plus ``select`` for
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, {
"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",
}

View File

@@ -2,9 +2,10 @@ from microdot import Microdot
from microdot.session import with_session
from models.preset import Preset
from models.profile import Profile
from models.device import Device, normalize_mac
from models.transport import get_current_sender
from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device
from util.espnow_message import build_message, build_preset_dict
import asyncio
import json
controller = Microdot()
@@ -125,13 +126,17 @@ async def delete_preset(request, *args, **kwargs):
@with_session
async def send_presets(request, session):
"""
Send one or more presets to the LED driver (via serial transport).
Send one or more presets to LED drivers (serial/ESP-NOW and/or TCP Wi-Fi clients).
Body JSON:
{"preset_ids": ["1", "2", ...]} or {"ids": ["1", "2", ...]}
Optional "targets": ["aabbccddeeff", ...] — registry MACs. When set: preset
chunks are ESP-NOW broadcast once each; Wi-Fi drivers get the same chunks
over TCP; if "default" is set, each target then gets a unicast default
message (serial or TCP) with that device name in "targets".
Omit targets for broadcast-only serial (legacy).
The controller looks up each preset, converts to API format, chunks into
<= 240-byte messages, and sends them over the configured transport.
Optional "destination_mac" / "to": single MAC when targets is omitted.
"""
try:
data = request.json or {}
@@ -144,7 +149,6 @@ async def send_presets(request, session):
save_flag = data.get('save', True)
save_flag = bool(save_flag)
default_id = data.get('default')
# Optional 12-char hex MAC to send to one device; omit for default (e.g. broadcast).
destination_mac = data.get('destination_mac') or data.get('to')
# Build API-compliant preset map keyed by preset ID, include name
@@ -171,23 +175,13 @@ async def send_presets(request, session):
if not sender:
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
async def send_chunk(chunk_presets, is_last):
# Save/default should only be sent with the final presets chunk.
msg = build_message(
presets=chunk_presets,
save=save_flag and is_last,
default=default_id if is_last else None,
)
await sender.send(msg, addr=destination_mac)
MAX_BYTES = 240
send_delay_s = 0.1
entries = list(presets_by_name.items())
total_presets = len(entries)
messages_sent = 0
batch = {}
last_msg = None
chunk_messages = []
for name, preset_obj in entries:
test_batch = dict(batch)
test_batch[name] = preset_obj
@@ -196,28 +190,133 @@ async def send_presets(request, session):
if size <= MAX_BYTES or not batch:
batch = test_batch
last_msg = test_msg
else:
try:
await send_chunk(batch, False)
except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
await asyncio.sleep(send_delay_s)
messages_sent += 1
chunk_messages.append(
build_message(
presets=dict(batch),
save=False,
default=None,
)
)
batch = {name: preset_obj}
last_msg = build_message(presets=batch, save=save_flag, default=default_id)
if batch:
try:
await send_chunk(batch, True)
except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
await asyncio.sleep(send_delay_s)
messages_sent += 1
chunk_messages.append(
build_message(
presets=dict(batch),
save=save_flag,
default=default_id,
)
)
target_list = None
raw_targets = data.get("targets")
if isinstance(raw_targets, list) and raw_targets:
target_list = []
for t in raw_targets:
m = normalize_mac(str(t))
if m:
target_list.append(m)
target_list = list(dict.fromkeys(target_list))
if not target_list:
target_list = None
elif destination_mac:
dm = normalize_mac(str(destination_mac))
target_list = [dm] if dm else None
try:
if target_list:
deliveries = await deliver_preset_broadcast_then_per_device(
sender,
chunk_messages,
target_list,
Device(),
str(default_id) if default_id is not None else None,
delay_s=send_delay_s,
)
else:
deliveries, _chunks = await deliver_json_messages(
sender,
chunk_messages,
None,
Device(),
delay_s=send_delay_s,
)
except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
return json.dumps({
"message": "Presets sent",
"presets_sent": total_presets,
"messages_sent": messages_sent
"messages_sent": deliveries,
}), 200, {'Content-Type': 'application/json'}
@controller.post('/push')
@with_session
async def push_driver_messages(request, session):
"""
Deliver one or more raw v1 JSON objects to devices (ESP-NOW and/or TCP).
Body:
{"sequence": [{ "v": "1", ... }, ...], "targets": ["mac", ...]}
or a single {"payload": {...}, "targets": [...]}.
"""
try:
data = request.json or {}
except Exception:
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
seq = data.get("sequence")
if not seq and data.get("payload") is not None:
seq = [data["payload"]]
if not isinstance(seq, list) or not seq:
return json.dumps({"error": "sequence or payload required"}), 400, {'Content-Type': 'application/json'}
raw_targets = data.get("targets")
target_list = None
if isinstance(raw_targets, list) and raw_targets:
target_list = []
for t in raw_targets:
m = normalize_mac(str(t))
if m:
target_list.append(m)
target_list = list(dict.fromkeys(target_list))
if not target_list:
target_list = None
sender = get_current_sender()
if not sender:
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
messages = []
for item in seq:
if isinstance(item, dict):
messages.append(json.dumps(item))
elif isinstance(item, str):
messages.append(item)
else:
return json.dumps({"error": "sequence items must be objects or strings"}), 400, {'Content-Type': 'application/json'}
delay_s = data.get("delay_s", 0.05)
try:
delay_s = float(delay_s)
except (TypeError, ValueError):
delay_s = 0.05
try:
deliveries, _chunks = await deliver_json_messages(
sender,
messages,
target_list,
Device(),
delay_s=delay_s,
)
except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
return json.dumps({
"message": "Delivered",
"deliveries": deliveries,
}), 200, {'Content-Type': 'application/json'}