145 lines
4.8 KiB
Python
145 lines
4.8 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from collections import deque
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from fastapi import WebSocket
|
|
|
|
from .models import ConnectedDevice, DeviceHello, Platform
|
|
|
|
|
|
def _utc_now_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
class ConnectionManager:
|
|
def __init__(self) -> None:
|
|
self._lock = asyncio.Lock()
|
|
self._sockets: dict[str, WebSocket] = {}
|
|
self._meta: dict[str, ConnectedDevice] = {}
|
|
self._events: deque[dict[str, Any]] = deque(maxlen=500)
|
|
|
|
async def connect(self, hello: DeviceHello, websocket: WebSocket) -> None:
|
|
now = _utc_now_iso()
|
|
async with self._lock:
|
|
self._sockets[hello.device_id] = websocket
|
|
self._meta[hello.device_id] = ConnectedDevice(
|
|
device_id=hello.device_id,
|
|
platform=hello.platform,
|
|
version=hello.version,
|
|
hostname=hello.hostname,
|
|
capabilities=hello.capabilities,
|
|
connected_at=now,
|
|
last_seen_at=now,
|
|
)
|
|
self._events.appendleft(
|
|
{
|
|
"time": now,
|
|
"event": "device_connected",
|
|
"device_id": hello.device_id,
|
|
"platform": hello.platform.value,
|
|
"version": hello.version,
|
|
}
|
|
)
|
|
|
|
async def disconnect(self, device_id: str) -> None:
|
|
now = _utc_now_iso()
|
|
async with self._lock:
|
|
had = device_id in self._sockets
|
|
self._sockets.pop(device_id, None)
|
|
if device_id in self._meta:
|
|
self._meta[device_id].last_seen_at = now
|
|
if had:
|
|
self._events.appendleft(
|
|
{
|
|
"time": now,
|
|
"event": "device_disconnected",
|
|
"device_id": device_id,
|
|
}
|
|
)
|
|
|
|
async def touch(self, device_id: str) -> None:
|
|
async with self._lock:
|
|
meta = self._meta.get(device_id)
|
|
if meta:
|
|
meta.last_seen_at = _utc_now_iso()
|
|
|
|
async def list_devices(self) -> list[ConnectedDevice]:
|
|
async with self._lock:
|
|
return [self._meta[did] for did in self._sockets.keys() if did in self._meta]
|
|
|
|
async def get_logs(self) -> list[dict[str, Any]]:
|
|
async with self._lock:
|
|
return list(self._events)
|
|
|
|
async def add_event(self, payload: dict[str, Any]) -> None:
|
|
async with self._lock:
|
|
payload = dict(payload)
|
|
payload.setdefault("time", _utc_now_iso())
|
|
self._events.appendleft(payload)
|
|
|
|
async def dispatch_command(
|
|
self,
|
|
command_id: str,
|
|
action: str,
|
|
data: dict[str, Any],
|
|
target_scope: str,
|
|
device_ids: list[str],
|
|
) -> tuple[list[str], list[str]]:
|
|
async with self._lock:
|
|
targets: list[tuple[str, WebSocket, ConnectedDevice]] = []
|
|
skipped: list[str] = []
|
|
|
|
for device_id, ws in self._sockets.items():
|
|
meta = self._meta.get(device_id)
|
|
if not meta:
|
|
continue
|
|
|
|
allowed = False
|
|
if target_scope == "all":
|
|
allowed = True
|
|
elif target_scope == "windows":
|
|
allowed = meta.platform == Platform.windows
|
|
elif target_scope == "pwa":
|
|
allowed = meta.platform == Platform.pwa
|
|
elif target_scope == "web":
|
|
allowed = meta.platform == Platform.web
|
|
elif target_scope == "device_ids":
|
|
allowed = device_id in device_ids
|
|
|
|
if allowed:
|
|
targets.append((device_id, ws, meta))
|
|
else:
|
|
skipped.append(device_id)
|
|
|
|
delivered: list[str] = []
|
|
for device_id, ws, _meta in targets:
|
|
try:
|
|
await ws.send_json(
|
|
{
|
|
"type": "command",
|
|
"id": command_id,
|
|
"action": action,
|
|
"data": data,
|
|
"sent_at": _utc_now_iso(),
|
|
}
|
|
)
|
|
delivered.append(device_id)
|
|
except Exception:
|
|
skipped.append(device_id)
|
|
await self.disconnect(device_id)
|
|
|
|
await self.add_event(
|
|
{
|
|
"event": "command_dispatched",
|
|
"command_id": command_id,
|
|
"action": action,
|
|
"target_scope": target_scope,
|
|
"delivered_count": len(delivered),
|
|
"delivered_to": delivered,
|
|
}
|
|
)
|
|
return delivered, skipped
|