|
2 | 2 | WebSocket management for DeviceTimer extension. |
3 | 3 |
|
4 | 4 | Handles device connections and message broadcasting. |
5 | | -Tracks connected devices to show real-time status in UI. |
| 5 | +Tracks connected hardware devices to show real-time status in UI. |
| 6 | +Browser connections (for watching payments) are tracked separately. |
6 | 7 | """ |
7 | 8 |
|
8 | | -from fastapi import APIRouter, WebSocket, WebSocketDisconnect |
| 9 | +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query |
9 | 10 | from loguru import logger |
10 | | -from typing import Dict, Set |
| 11 | +from typing import Dict, Set, Optional |
11 | 12 |
|
12 | 13 | devicetimer_websocket_router = APIRouter() |
13 | 14 |
|
14 | | -# Track connected devices: device_id -> set of WebSocket connections |
15 | | -# Multiple connections per device are supported (e.g., multiple browser tabs) |
16 | | -_connected_clients: Dict[str, Set[WebSocket]] = {} |
| 15 | +# Track hardware device connections: device_id -> set of WebSocket connections |
| 16 | +_hardware_clients: Dict[str, Set[WebSocket]] = {} |
| 17 | + |
| 18 | +# Track browser connections (for payment notifications in UI) |
| 19 | +_browser_clients: Dict[str, Set[WebSocket]] = {} |
17 | 20 |
|
18 | 21 |
|
19 | 22 | def get_connected_device_ids() -> list[str]: |
20 | | - """Return list of device IDs with active WebSocket connections.""" |
21 | | - return list(_connected_clients.keys()) |
| 23 | + """Return list of device IDs with active hardware connections.""" |
| 24 | + return list(_hardware_clients.keys()) |
22 | 25 |
|
23 | 26 |
|
24 | 27 | def is_device_connected(device_id: str) -> bool: |
25 | | - """Check if a device has any active WebSocket connections.""" |
26 | | - return device_id in _connected_clients and len(_connected_clients[device_id]) > 0 |
| 28 | + """Check if a hardware device has any active WebSocket connections.""" |
| 29 | + return device_id in _hardware_clients and len(_hardware_clients[device_id]) > 0 |
27 | 30 |
|
28 | 31 |
|
29 | 32 | async def send_to_device(device_id: str, message: str) -> bool: |
30 | 33 | """ |
31 | | - Send a message to all WebSocket connections for a device. |
| 34 | + Send a message to all WebSocket connections for a device (hardware + browser). |
32 | 35 | Returns True if message was sent to at least one client. |
33 | 36 | """ |
34 | | - if device_id not in _connected_clients: |
35 | | - logger.warning(f"No WebSocket connections for device {device_id}") |
36 | | - return False |
37 | | - |
38 | 37 | sent = False |
39 | | - dead_connections: Set[WebSocket] = set() |
40 | | - |
41 | | - for websocket in _connected_clients[device_id]: |
42 | | - try: |
43 | | - await websocket.send_text(message) |
44 | | - sent = True |
45 | | - except Exception as e: |
46 | | - logger.debug(f"Failed to send to WebSocket: {e}") |
47 | | - dead_connections.add(websocket) |
48 | 38 |
|
49 | | - # Clean up dead connections |
50 | | - for ws in dead_connections: |
51 | | - _connected_clients[device_id].discard(ws) |
52 | | - |
53 | | - # Remove device entry if no connections left |
54 | | - if device_id in _connected_clients and not _connected_clients[device_id]: |
55 | | - del _connected_clients[device_id] |
| 39 | + # Send to hardware clients |
| 40 | + if device_id in _hardware_clients: |
| 41 | + dead_connections: Set[WebSocket] = set() |
| 42 | + for websocket in _hardware_clients[device_id]: |
| 43 | + try: |
| 44 | + await websocket.send_text(message) |
| 45 | + sent = True |
| 46 | + except Exception as e: |
| 47 | + logger.debug(f"Failed to send to hardware: {e}") |
| 48 | + dead_connections.add(websocket) |
| 49 | + for ws in dead_connections: |
| 50 | + _hardware_clients[device_id].discard(ws) |
| 51 | + if not _hardware_clients[device_id]: |
| 52 | + del _hardware_clients[device_id] |
| 53 | + |
| 54 | + # Send to browser clients (so UI shows payment received) |
| 55 | + if device_id in _browser_clients: |
| 56 | + dead_connections = set() |
| 57 | + for websocket in _browser_clients[device_id]: |
| 58 | + try: |
| 59 | + await websocket.send_text(message) |
| 60 | + sent = True |
| 61 | + except Exception as e: |
| 62 | + logger.debug(f"Failed to send to browser: {e}") |
| 63 | + dead_connections.add(websocket) |
| 64 | + for ws in dead_connections: |
| 65 | + _browser_clients[device_id].discard(ws) |
| 66 | + if not _browser_clients[device_id]: |
| 67 | + del _browser_clients[device_id] |
| 68 | + |
| 69 | + if not sent: |
| 70 | + logger.warning(f"No WebSocket connections for device {device_id}") |
56 | 71 |
|
57 | 72 | return sent |
58 | 73 |
|
59 | 74 |
|
60 | 75 | @devicetimer_websocket_router.websocket("/api/v1/ws/{device_id}") |
61 | | -async def websocket_endpoint(websocket: WebSocket, device_id: str): |
| 76 | +async def websocket_endpoint( |
| 77 | + websocket: WebSocket, |
| 78 | + device_id: str, |
| 79 | + type: Optional[str] = Query(default="hardware") |
| 80 | +): |
62 | 81 | """ |
63 | 82 | WebSocket endpoint for device connections. |
64 | | - Hardware devices connect here to receive payment notifications. |
| 83 | +
|
| 84 | + Query params: |
| 85 | + type: "hardware" (default) for ESP32 devices, "browser" for UI connections |
65 | 86 | """ |
66 | 87 | await websocket.accept() |
67 | 88 |
|
| 89 | + # Select the appropriate client pool |
| 90 | + is_browser = type == "browser" |
| 91 | + clients = _browser_clients if is_browser else _hardware_clients |
| 92 | + client_type = "browser" if is_browser else "hardware" |
| 93 | + |
68 | 94 | # Add to tracking |
69 | | - if device_id not in _connected_clients: |
70 | | - _connected_clients[device_id] = set() |
71 | | - _connected_clients[device_id].add(websocket) |
| 95 | + if device_id not in clients: |
| 96 | + clients[device_id] = set() |
| 97 | + clients[device_id].add(websocket) |
72 | 98 |
|
73 | | - logger.info(f"Device {device_id} connected. Total connections: {len(_connected_clients[device_id])}") |
| 99 | + logger.info(f"{client_type.capitalize()} connected for device {device_id}") |
74 | 100 |
|
75 | 101 | try: |
76 | 102 | while True: |
77 | | - # Keep connection alive, wait for messages (ping/pong handled automatically) |
78 | 103 | data = await websocket.receive_text() |
79 | | - # Hardware might send status updates, we just acknowledge |
80 | | - logger.debug(f"Received from {device_id}: {data}") |
| 104 | + logger.debug(f"Received from {device_id} ({client_type}): {data}") |
81 | 105 | except WebSocketDisconnect: |
82 | | - logger.info(f"Device {device_id} disconnected") |
| 106 | + logger.info(f"{client_type.capitalize()} disconnected for device {device_id}") |
83 | 107 | except Exception as e: |
84 | 108 | logger.debug(f"WebSocket error for {device_id}: {e}") |
85 | 109 | finally: |
86 | | - # Remove from tracking |
87 | | - if device_id in _connected_clients: |
88 | | - _connected_clients[device_id].discard(websocket) |
89 | | - if not _connected_clients[device_id]: |
90 | | - del _connected_clients[device_id] |
91 | | - logger.info(f"Device {device_id} cleaned up. Connected devices: {list(_connected_clients.keys())}") |
| 110 | + if device_id in clients: |
| 111 | + clients[device_id].discard(websocket) |
| 112 | + if not clients[device_id]: |
| 113 | + del clients[device_id] |
| 114 | + logger.debug(f"Connected hardware devices: {list(_hardware_clients.keys())}") |
92 | 115 |
|
93 | 116 |
|
94 | 117 | @devicetimer_websocket_router.get("/api/v1/ws/status") |
|
0 commit comments