websocket.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. import asyncio
  2. import json
  3. from typing import Any
  4. from fastapi import WebSocket
  5. class ConnectionManager:
  6. """Manages WebSocket connections and broadcasts."""
  7. def __init__(self):
  8. self.active_connections: list[WebSocket] = []
  9. self._lock = asyncio.Lock()
  10. async def connect(self, websocket: WebSocket):
  11. """Accept a new WebSocket connection."""
  12. await websocket.accept()
  13. async with self._lock:
  14. self.active_connections.append(websocket)
  15. async def disconnect(self, websocket: WebSocket):
  16. """Remove a WebSocket connection."""
  17. async with self._lock:
  18. if websocket in self.active_connections:
  19. self.active_connections.remove(websocket)
  20. async def broadcast(self, message: dict[str, Any]):
  21. """Broadcast a message to all connected clients."""
  22. if not self.active_connections:
  23. return
  24. data = json.dumps(message)
  25. async with self._lock:
  26. disconnected = []
  27. for connection in self.active_connections:
  28. try:
  29. await connection.send_text(data)
  30. except Exception:
  31. disconnected.append(connection)
  32. # Clean up disconnected clients
  33. for conn in disconnected:
  34. if conn in self.active_connections:
  35. self.active_connections.remove(conn)
  36. async def broadcast_to_user(self, user_id: int | None, message: dict[str, Any]):
  37. """Send a message to every connection authenticated as the given user.
  38. When ``user_id`` is None the message fans out to all connections —
  39. this is the auth-disabled single-user path, where neither the queue
  40. item's ``created_by_id`` nor the WS principal is set, and the
  41. existing fan-out semantics are exactly what the user wants.
  42. Per-user routing reads ``websocket.state.bambuddy_principal_user_id``
  43. stamped at connect time (``routes/websocket.py``). Connections
  44. without a stamped id are skipped on the targeted path so an
  45. anonymous reader never receives another user's dispatch toast.
  46. """
  47. if user_id is None:
  48. await self.broadcast(message)
  49. return
  50. if not self.active_connections:
  51. return
  52. data = json.dumps(message)
  53. async with self._lock:
  54. disconnected = []
  55. for connection in self.active_connections:
  56. conn_uid = getattr(connection.state, "bambuddy_principal_user_id", None)
  57. if conn_uid != user_id:
  58. continue
  59. try:
  60. await connection.send_text(data)
  61. except Exception:
  62. disconnected.append(connection)
  63. for conn in disconnected:
  64. if conn in self.active_connections:
  65. self.active_connections.remove(conn)
  66. async def send_printer_status(self, printer_id: int, status: dict):
  67. """Send printer status update to all clients."""
  68. await self.broadcast(
  69. {
  70. "type": "printer_status",
  71. "printer_id": printer_id,
  72. "data": status,
  73. }
  74. )
  75. async def send_print_start(self, printer_id: int, data: dict):
  76. """Notify clients that a print has started."""
  77. await self.broadcast(
  78. {
  79. "type": "print_start",
  80. "printer_id": printer_id,
  81. "data": data,
  82. }
  83. )
  84. async def send_print_complete(self, printer_id: int, data: dict):
  85. """Notify clients that a print has completed."""
  86. await self.broadcast(
  87. {
  88. "type": "print_complete",
  89. "printer_id": printer_id,
  90. "data": data,
  91. }
  92. )
  93. async def send_print_confirm_request(self, printer_id: int, data: dict):
  94. """Ask connected clients for a post-print outcome verdict (#1898)."""
  95. await self.broadcast(
  96. {
  97. "type": "print_confirm_request",
  98. "printer_id": printer_id,
  99. "data": data,
  100. }
  101. )
  102. async def send_archive_created(self, archive: dict):
  103. """Notify clients that a new archive was created."""
  104. await self.broadcast(
  105. {
  106. "type": "archive_created",
  107. "data": archive,
  108. }
  109. )
  110. async def send_archive_updated(self, archive: dict):
  111. """Notify clients that an archive was updated."""
  112. await self.broadcast(
  113. {
  114. "type": "archive_updated",
  115. "data": archive,
  116. }
  117. )
  118. async def send_queue_item_uploading(
  119. self,
  120. user_id: int | None,
  121. queue_item_id: int,
  122. printer_id: int,
  123. printer_name: str | None,
  124. file_name: str,
  125. total_bytes: int,
  126. ):
  127. """Toast trigger: scheduler picked the item up, FTP upload starts."""
  128. await self.broadcast_to_user(
  129. user_id,
  130. {
  131. "type": "queue_item_uploading",
  132. "queue_item_id": queue_item_id,
  133. "printer_id": printer_id,
  134. "printer_name": printer_name,
  135. "file_name": file_name,
  136. "total_bytes": total_bytes,
  137. },
  138. )
  139. async def send_queue_item_upload_progress(
  140. self,
  141. user_id: int | None,
  142. queue_item_id: int,
  143. bytes_transferred: int,
  144. total_bytes: int,
  145. ):
  146. """Toast update: throttled byte-level progress during the FTP upload."""
  147. pct = int(round(100 * bytes_transferred / total_bytes)) if total_bytes else 0
  148. await self.broadcast_to_user(
  149. user_id,
  150. {
  151. "type": "queue_item_upload_progress",
  152. "queue_item_id": queue_item_id,
  153. "bytes_transferred": bytes_transferred,
  154. "total_bytes": total_bytes,
  155. "pct": pct,
  156. },
  157. )
  158. async def send_queue_item_acked(
  159. self,
  160. user_id: int | None,
  161. queue_item_id: int,
  162. printer_id: int,
  163. ):
  164. """Toast trigger: watchdog confirmed the printer transitioned out of pre_state."""
  165. await self.broadcast_to_user(
  166. user_id,
  167. {
  168. "type": "queue_item_acked",
  169. "queue_item_id": queue_item_id,
  170. "printer_id": printer_id,
  171. },
  172. )
  173. async def send_queue_item_failed(
  174. self,
  175. user_id: int | None,
  176. queue_item_id: int,
  177. printer_id: int | None,
  178. reason: str,
  179. ):
  180. """Toast trigger: dispatch failed at any stage. Toast turns red, auto-dismisses."""
  181. await self.broadcast_to_user(
  182. user_id,
  183. {
  184. "type": "queue_item_failed",
  185. "queue_item_id": queue_item_id,
  186. "printer_id": printer_id,
  187. "reason": reason,
  188. },
  189. )
  190. async def send_missing_spool_assignment(
  191. self,
  192. printer_id: int,
  193. printer_name: str,
  194. missing_slots: list[dict[str, str]],
  195. ):
  196. """Notify clients that a print started with missing spool assignments."""
  197. await self.broadcast(
  198. {
  199. "type": "missing_spool_assignment",
  200. "printer_id": printer_id,
  201. "printer_name": printer_name,
  202. "missing_slots": missing_slots,
  203. }
  204. )
  205. # Global connection manager
  206. ws_manager = ConnectionManager()