rr-locker-api / app /services /command_hub.py
MD.Riad Rayhan
Remove provisioning/device-owner endpoints; Device Admin only
c60642c verified
Raw History Blame Contribute Delete
3.04 kB
"""Real-time command push hub.
Each enrolled device keeps a persistent WebSocket open to
``/api/v1/commands/ws/{device_id}``. When the admin panel issues a command we
``db.commit()`` it (so the poll fallback still works) and then *wake* the device
over its socket, so the app fetches and executes the command instantly instead
of waiting for the next 2s poll.
The hub holds no durable state — if the API restarts, devices reconnect via
``StreamingWsClient`` automatically and the 2s poll covers the gap.
"""
from __future__ import annotations
import asyncio
import logging
from collections import defaultdict
from typing import Dict, Optional, Set
from fastapi import WebSocket
logger = logging.getLogger(__name__)
class CommandHub:
"""In-memory registry of device command sockets keyed by ``device_id``."""
def __init__(self) -> None:
self._devices: Dict[str, Set[WebSocket]] = defaultdict(set)
self._lock = asyncio.Lock()
# Captured on the first WS connect so synchronous request handlers
# (which run in a threadpool) can schedule a wake on the event loop.
self._loop: Optional[asyncio.AbstractEventLoop] = None
async def attach(self, device_id: str, ws: WebSocket) -> None:
self._loop = asyncio.get_running_loop()
async with self._lock:
self._devices[device_id].add(ws)
logger.info("CommandHub: device attached device=%s total=%d",
device_id, len(self._devices[device_id]))
async def detach(self, device_id: str, ws: WebSocket) -> None:
async with self._lock:
self._devices[device_id].discard(ws)
if not self._devices[device_id]:
self._devices.pop(device_id, None)
def is_online(self, device_id: str) -> bool:
return bool(self._devices.get(device_id))
async def _wake(self, device_id: str) -> None:
targets = list(self._devices.get(device_id, ()))
if not targets:
return
dead: list[WebSocket] = []
for ws in targets:
try:
await ws.send_text('{"event":"command"}')
except Exception:
dead.append(ws)
if dead:
async with self._lock:
for d in dead:
self._devices[device_id].discard(d)
def wake_threadsafe(self, device_id) -> None:
"""Wake a device from synchronous code (e.g. a request handler).
Safe to call even if no event loop / socket is available — it is a
best-effort nudge layered on top of the existing poll + FCM fallbacks.
"""
loop = self._loop
if loop is None:
return
try:
asyncio.run_coroutine_threadsafe(self._wake(str(device_id)), loop)
except Exception as exc: # pragma: no cover - best effort
logger.debug("CommandHub wake failed for %s: %s", device_id, exc)
command_hub = CommandHub()