chore: add pattern samples, http driver helpers, OTA/UDP test tools
- patterns/: sample dynamic pattern modules for OTA - esp32/msg.json: example bridge message shape - models/http_driver.py, wifi_peer.py: Wi-Fi driver HTTP poll helpers - tests: pattern OTA send script and UDP discovery echo server - Submodule led-driver: http_poll and test utilities Made-with: Cursor
This commit is contained in:
125
src/models/http_driver.py
Normal file
125
src/models/http_driver.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Wi-Fi LED drivers over HTTP long-poll (same port as the web UI).
|
||||
|
||||
Drivers POST /driver/v1/poll; the controller responds with queued JSON lines.
|
||||
Presence: last poll within DRIVER_HTTP_SEEN_S counts as connected.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from models.wifi_peer import normalize_wifi_peer_ip
|
||||
|
||||
# Must exceed max ``wait_s`` (60) on /driver/v1/poll so sessions are not pruned mid-wait.
|
||||
DRIVER_HTTP_SEEN_S = 90.0
|
||||
_QUEUE_MAX = 64
|
||||
|
||||
_queues: dict[str, asyncio.Queue] = {}
|
||||
_last_poll: dict[str, float] = {}
|
||||
_connected_flag: set[str] = set()
|
||||
_status_broadcast = None
|
||||
|
||||
|
||||
def set_wifi_driver_status_broadcaster(coro) -> None:
|
||||
global _status_broadcast
|
||||
_status_broadcast = coro
|
||||
|
||||
|
||||
def _schedule_status(ip: str, connected: bool) -> None:
|
||||
fn = _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 _get_queue(ip: str) -> asyncio.Queue:
|
||||
q = _queues.get(ip)
|
||||
if q is None:
|
||||
q = asyncio.Queue(maxsize=_QUEUE_MAX)
|
||||
_queues[ip] = q
|
||||
return q
|
||||
|
||||
|
||||
def prune_stale_http_sessions() -> None:
|
||||
"""Drop timed-out sessions, clear queues, broadcast disconnect."""
|
||||
now = time.monotonic()
|
||||
for ip in list(_last_poll.keys()):
|
||||
if now - _last_poll[ip] <= DRIVER_HTTP_SEEN_S:
|
||||
continue
|
||||
_last_poll.pop(ip, None)
|
||||
_queues.pop(ip, None)
|
||||
if ip in _connected_flag:
|
||||
_connected_flag.discard(ip)
|
||||
_schedule_status(ip, False)
|
||||
print(f"[HTTP driver] session timed out: {ip}")
|
||||
|
||||
|
||||
def touch_http_session(ip: str) -> None:
|
||||
ip = normalize_wifi_peer_ip(ip)
|
||||
if not ip:
|
||||
return
|
||||
prune_stale_http_sessions()
|
||||
now = time.monotonic()
|
||||
_last_poll[ip] = now
|
||||
if ip not in _connected_flag:
|
||||
_connected_flag.add(ip)
|
||||
_schedule_status(ip, True)
|
||||
|
||||
|
||||
def wifi_driver_connected(ip: str) -> bool:
|
||||
prune_stale_http_sessions()
|
||||
key = normalize_wifi_peer_ip(ip)
|
||||
return bool(key and key in _connected_flag)
|
||||
|
||||
|
||||
def list_connected_driver_ips():
|
||||
prune_stale_http_sessions()
|
||||
return list(_connected_flag)
|
||||
|
||||
|
||||
async def enqueue_json_line(ip: str, json_str: str) -> bool:
|
||||
ip = normalize_wifi_peer_ip(ip)
|
||||
if not ip:
|
||||
return False
|
||||
line = json_str[:-1] if json_str.endswith("\n") else json_str
|
||||
q = _get_queue(ip)
|
||||
while True:
|
||||
try:
|
||||
q.put_nowait(line)
|
||||
return True
|
||||
except asyncio.QueueFull:
|
||||
try:
|
||||
q.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
|
||||
|
||||
async def send_json_line_to_ip(ip: str, json_str: str) -> bool:
|
||||
"""Queue one JSON line for the driver to receive on the next long-poll."""
|
||||
return await enqueue_json_line(ip, json_str)
|
||||
|
||||
|
||||
async def collect_lines_after_touch(ip: str, wait_s: float) -> list[str]:
|
||||
"""Wait up to wait_s for first line, then drain the rest (non-blocking)."""
|
||||
ip = normalize_wifi_peer_ip(ip)
|
||||
if not ip:
|
||||
return []
|
||||
q = _get_queue(ip)
|
||||
lines: list[str] = []
|
||||
try:
|
||||
first = await asyncio.wait_for(q.get(), timeout=wait_s)
|
||||
lines.append(first)
|
||||
while True:
|
||||
try:
|
||||
lines.append(q.get_nowait())
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
return lines
|
||||
8
src/models/wifi_peer.py
Normal file
8
src/models/wifi_peer.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""Normalise Wi-Fi client addresses (strip IPv4-mapped IPv6 prefix)."""
|
||||
|
||||
|
||||
def normalize_wifi_peer_ip(ip: str) -> str:
|
||||
s = str(ip).strip()
|
||||
if s.lower().startswith("::ffff:"):
|
||||
s = s[7:]
|
||||
return s
|
||||
Reference in New Issue
Block a user