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:
@@ -233,10 +233,10 @@ class Device(Model):
|
||||
def list(self):
|
||||
return list(self.keys())
|
||||
|
||||
def upsert_wifi_tcp_client(self, device_name, peer_ip, mac):
|
||||
def upsert_wifi_tcp_client(self, device_name, peer_ip, mac, device_type=None):
|
||||
"""
|
||||
Register or update a Wi-Fi client by **MAC** (storage id). Updates **name**
|
||||
and **address** (peer IP) on each connect.
|
||||
Register or update a Wi-Fi client by **MAC** (storage id). Updates **name**,
|
||||
**address** (peer IP), and optionally **type** from the client hello when valid.
|
||||
"""
|
||||
mac_hex = normalize_mac(mac)
|
||||
if not mac_hex:
|
||||
@@ -247,10 +247,19 @@ class Device(Model):
|
||||
ip = normalize_address_for_transport(peer_ip, "wifi")
|
||||
if not ip:
|
||||
return None
|
||||
resolved_type = None
|
||||
if device_type is not None:
|
||||
try:
|
||||
resolved_type = validate_device_type(device_type)
|
||||
except ValueError:
|
||||
resolved_type = None
|
||||
if mac_hex in self:
|
||||
merged = dict(self[mac_hex])
|
||||
merged["name"] = name
|
||||
merged["type"] = validate_device_type(merged.get("type"))
|
||||
if resolved_type is not None:
|
||||
merged["type"] = resolved_type
|
||||
else:
|
||||
merged["type"] = validate_device_type(merged.get("type"))
|
||||
merged["transport"] = "wifi"
|
||||
merged["address"] = ip
|
||||
merged["id"] = mac_hex
|
||||
@@ -260,7 +269,7 @@ class Device(Model):
|
||||
self[mac_hex] = {
|
||||
"id": mac_hex,
|
||||
"name": name,
|
||||
"type": "led",
|
||||
"type": resolved_type or "led",
|
||||
"transport": "wifi",
|
||||
"address": ip,
|
||||
"default_pattern": None,
|
||||
|
||||
115
src/models/tcp_clients.py
Normal file
115
src/models/tcp_clients.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Track connected Wi-Fi LED drivers (TCP clients) for outbound JSON lines."""
|
||||
|
||||
import asyncio
|
||||
|
||||
_writers = {}
|
||||
|
||||
|
||||
def prune_stale_tcp_writers() -> None:
|
||||
"""Remove writers that are already closing so the UI does not stay online."""
|
||||
stale = [(ip, w) for ip, w in list(_writers.items()) if w.is_closing()]
|
||||
for ip, w in stale:
|
||||
unregister_tcp_writer(ip, w)
|
||||
|
||||
|
||||
def normalize_tcp_peer_ip(ip: str) -> str:
|
||||
"""Match asyncio peer addresses to registry IPs (strip IPv4-mapped IPv6 prefix)."""
|
||||
s = str(ip).strip()
|
||||
if s.lower().startswith("::ffff:"):
|
||||
s = s[7:]
|
||||
return s
|
||||
# Optional ``async def (ip: str, connected: bool) -> None`` set from ``main``.
|
||||
_tcp_status_broadcast = None
|
||||
|
||||
|
||||
def set_tcp_status_broadcaster(coro) -> None:
|
||||
global _tcp_status_broadcast
|
||||
_tcp_status_broadcast = coro
|
||||
|
||||
|
||||
def _schedule_tcp_status_broadcast(ip: str, connected: bool) -> None:
|
||||
fn = _tcp_status_broadcast
|
||||
if not fn:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
try:
|
||||
loop.create_task(fn(ip, connected))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def register_tcp_writer(peer_ip: str, writer) -> None:
|
||||
if not peer_ip:
|
||||
return
|
||||
key = normalize_tcp_peer_ip(peer_ip)
|
||||
if not key:
|
||||
return
|
||||
old = _writers.get(key)
|
||||
_writers[key] = writer
|
||||
_schedule_tcp_status_broadcast(key, True)
|
||||
if old is not None and old is not writer:
|
||||
try:
|
||||
old.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def unregister_tcp_writer(peer_ip: str, writer=None) -> str:
|
||||
"""
|
||||
Remove the writer for peer_ip. If ``writer`` is given, only pop when it is still
|
||||
the registered instance (avoids a replaced TCP session removing the new one).
|
||||
|
||||
Returns ``removed`` (cleared live session + UI offline), ``noop`` (already gone),
|
||||
or ``superseded`` (this writer is not the registered one for that IP).
|
||||
"""
|
||||
if not peer_ip:
|
||||
return "noop"
|
||||
key = normalize_tcp_peer_ip(peer_ip)
|
||||
if not key:
|
||||
return "noop"
|
||||
current = _writers.get(key)
|
||||
if writer is not None:
|
||||
if current is None:
|
||||
return "noop"
|
||||
if current is not writer:
|
||||
return "superseded"
|
||||
had = key in _writers
|
||||
if had:
|
||||
_writers.pop(key, None)
|
||||
_schedule_tcp_status_broadcast(key, False)
|
||||
print(f"[TCP] device disconnected: {key}")
|
||||
return "removed"
|
||||
return "noop"
|
||||
|
||||
|
||||
def list_connected_ips():
|
||||
"""IPs with an active TCP writer (for UI snapshot)."""
|
||||
prune_stale_tcp_writers()
|
||||
return list(_writers.keys())
|
||||
|
||||
|
||||
def tcp_client_connected(ip: str) -> bool:
|
||||
"""True if a Wi-Fi driver is connected on this IP (TCP writer registered)."""
|
||||
prune_stale_tcp_writers()
|
||||
key = normalize_tcp_peer_ip(ip)
|
||||
return bool(key and key in _writers)
|
||||
|
||||
|
||||
async def send_json_line_to_ip(ip: str, json_str: str) -> bool:
|
||||
"""Send one newline-terminated JSON message to a connected TCP client."""
|
||||
ip = normalize_tcp_peer_ip(ip)
|
||||
writer = _writers.get(ip)
|
||||
if not writer:
|
||||
return False
|
||||
try:
|
||||
line = json_str if json_str.endswith("\n") else json_str + "\n"
|
||||
writer.write(line.encode("utf-8"))
|
||||
await writer.drain()
|
||||
return True
|
||||
except Exception as exc:
|
||||
print(f"[TCP] send to {ip} failed: {exc}")
|
||||
unregister_tcp_writer(ip, writer)
|
||||
return False
|
||||
@@ -39,11 +39,13 @@ class SerialSender:
|
||||
|
||||
self._serial = serial.Serial(port, baudrate=baudrate, timeout=1)
|
||||
self._default_addr = _parse_mac(default_addr)
|
||||
self._write_lock = asyncio.Lock()
|
||||
|
||||
async def send(self, data, addr=None):
|
||||
mac = _parse_mac(addr) if addr is not None else self._default_addr
|
||||
payload = _encode_payload(data)
|
||||
await _to_thread(self._serial.write, mac + payload)
|
||||
async with self._write_lock:
|
||||
await _to_thread(self._serial.write, mac + payload)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user