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:
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user