camera_fanout.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  1. """MJPEG fan-out broadcaster for camera streams.
  2. Most Bambu Lab printers only allow one concurrent camera connection: the
  3. RTSP socket on X1/H2/P2 models, the chamber-image socket on port 6000 on
  4. A1/P1 models. Without fan-out, opening a second viewer either fails or
  5. kicks the first viewer off — see issue #1089.
  6. This module owns a single upstream connection per printer and pushes each
  7. frame to N independent subscriber queues. New viewers tap the existing
  8. upstream; no new printer connection is opened. When the last subscriber
  9. leaves, the upstream is torn down after a short grace window so that a
  10. quick page refresh or second-tab open does not pay a reconnect.
  11. """
  12. from __future__ import annotations
  13. import asyncio
  14. import logging
  15. from collections.abc import AsyncGenerator, Awaitable, Callable
  16. logger = logging.getLogger(__name__)
  17. # How long to keep the upstream pump alive after the last subscriber leaves.
  18. # A short grace window absorbs page refreshes and "open camera in new tab"
  19. # without paying a fresh ffmpeg/RTSP handshake (which can take several seconds
  20. # on some firmwares and is the very reconnect cost we are trying to avoid).
  21. _GRACE_SECONDS = 5.0
  22. # Upper bound on how long a new broadcaster waits for a displaced one to finish
  23. # tearing down before proceeding anyway (#2521). Teardown is normally sub-second
  24. # (cancel pump + close socket); the cap only guards a wedged upstream close.
  25. _TEARDOWN_WAIT_SECONDS = 10.0
  26. # Per-subscriber queue depth. Small on purpose: if a viewer can't keep up
  27. # with the printer's frame rate we drop frames for that viewer rather than
  28. # blocking the broadcaster. Live video — old frames have no value.
  29. _SUBSCRIBER_QUEUE_SIZE = 4
  30. # Sentinel pushed to subscriber queues when the upstream pump exits, so each
  31. # subscriber's read loop can break out cleanly instead of hanging on get().
  32. _UPSTREAM_GONE = b""
  33. UpstreamFactory = Callable[[asyncio.Event], AsyncGenerator[bytes, None]]
  34. class MjpegBroadcaster:
  35. """Single upstream MJPEG stream, fanned out to N subscribers."""
  36. def __init__(self, key: str, factory: UpstreamFactory, predecessor: MjpegBroadcaster | None = None) -> None:
  37. self._key = key
  38. self._factory = factory
  39. self._subscribers: list[asyncio.Queue[bytes]] = []
  40. self._lock = asyncio.Lock()
  41. self._pump_task: asyncio.Task | None = None
  42. self._grace_task: asyncio.Task | None = None
  43. # Disconnect event passed to the upstream generator so we can ask it to
  44. # stop reconnecting when the last subscriber leaves.
  45. self._upstream_disconnect = asyncio.Event()
  46. self._stopped = False
  47. # Most recent chunk pumped to subscribers. New (late) subscribers are
  48. # primed with it so the browser renders a frame immediately instead of
  49. # waiting for the next upstream frame — critical on slow chamber-image
  50. # cams where the wait looked like a permanent black screen (#2521).
  51. self._last_chunk: bytes | None = None
  52. # Set once teardown is fully complete (pump cancelled AND the upstream
  53. # socket closed). A successor broadcaster waits on this before dialing
  54. # so a single-connection printer never sees two sockets at once — the
  55. # overlap stranded frames on an orphaned socket for the ~20 min it took
  56. # the printer's TCP keepalive to reap it (#2521).
  57. self._teardown_complete = asyncio.Event()
  58. # The stopped broadcaster this one replaces, if any. The pump waits for
  59. # its socket to close before opening ours. Guarding at the pump (not at
  60. # get_or_create) keeps it correct when concurrent viewers race to
  61. # replace the same stopped broadcaster — only the single pump dials.
  62. self._predecessor = predecessor
  63. @property
  64. def key(self) -> str:
  65. return self._key
  66. @property
  67. def subscriber_count(self) -> int:
  68. return len(self._subscribers)
  69. @property
  70. def stopped(self) -> bool:
  71. return self._stopped
  72. async def subscribe(self) -> asyncio.Queue[bytes]:
  73. """Add a subscriber and ensure the upstream pump is running."""
  74. async with self._lock:
  75. if self._stopped:
  76. raise RuntimeError(f"broadcaster {self._key!r} is stopped")
  77. # Cancel any pending grace-window shutdown — a viewer just rejoined.
  78. if self._grace_task is not None and not self._grace_task.done():
  79. self._grace_task.cancel()
  80. self._grace_task = None
  81. queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=_SUBSCRIBER_QUEUE_SIZE)
  82. self._subscribers.append(queue)
  83. # Prime a late joiner with the last frame so it renders instantly
  84. # (#2521). The very first subscriber has nothing to prime yet — it
  85. # starts the pump below.
  86. if self._last_chunk is not None:
  87. try:
  88. queue.put_nowait(self._last_chunk)
  89. except asyncio.QueueFull: # pragma: no cover — fresh queue
  90. pass
  91. if self._pump_task is None or self._pump_task.done():
  92. # Reset the disconnect signal in case a previous pump set it.
  93. self._upstream_disconnect = asyncio.Event()
  94. self._pump_task = asyncio.create_task(self._pump(), name=f"camera-fanout-pump-{self._key}")
  95. return queue
  96. async def unsubscribe(self, queue: asyncio.Queue[bytes]) -> int:
  97. """Remove a subscriber and return the remaining count (atomic).
  98. If this was the last subscriber, schedule grace shutdown.
  99. """
  100. async with self._lock:
  101. try:
  102. self._subscribers.remove(queue)
  103. except ValueError:
  104. return len(self._subscribers) # Already removed (e.g. force_shutdown)
  105. remaining = len(self._subscribers)
  106. if remaining == 0 and not self._stopped:
  107. # Last subscriber left. Schedule grace-window teardown.
  108. self._grace_task = asyncio.create_task(self._grace_then_stop(), name=f"camera-fanout-grace-{self._key}")
  109. return remaining
  110. async def force_shutdown(self) -> None:
  111. """Tear down immediately, kick all subscribers. Idempotent."""
  112. pump_task = await self._mark_stopped_locked(notify_subscribers=True)
  113. await self._await_pump_cancellation(pump_task)
  114. # Upstream socket is now closed (pump's finally ran) — release anyone
  115. # waiting to open a replacement broadcaster (#2521).
  116. self._teardown_complete.set()
  117. async def wait_until_torn_down(self) -> None:
  118. """Block until this broadcaster's upstream socket has fully closed.
  119. Only meaningful for a stopped broadcaster; on a live one this never
  120. returns. get_or_create_broadcaster gates a replacement on it so the
  121. old and new upstream sockets never overlap (#2521).
  122. """
  123. await self._teardown_complete.wait()
  124. async def _grace_then_stop(self) -> None:
  125. try:
  126. await asyncio.sleep(_GRACE_SECONDS)
  127. except asyncio.CancelledError:
  128. return # New subscriber arrived during grace
  129. # Re-check under the lock — a subscriber may have rejoined between
  130. # the sleep finishing and us acquiring the lock.
  131. pump_task: asyncio.Task | None = None
  132. async with self._lock:
  133. if self._subscribers or self._stopped:
  134. return
  135. self._upstream_disconnect.set()
  136. pump_task = self._pump_task
  137. self._pump_task = None
  138. self._grace_task = None
  139. self._stopped = True
  140. await self._await_pump_cancellation(pump_task)
  141. # Upstream socket is now closed — release any pending replacement (#2521).
  142. self._teardown_complete.set()
  143. async def _mark_stopped_locked(self, *, notify_subscribers: bool) -> asyncio.Task | None:
  144. """Mark the broadcaster stopped and detach the pump task.
  145. Caller MUST NOT hold ``self._lock`` (we acquire it here). Returns the
  146. pump task (if any) so the caller can await its cancellation OUTSIDE
  147. the lock — the pump's ``finally`` block needs the lock to wake up
  148. subscribers, so we'd deadlock if we awaited it under the lock.
  149. """
  150. async with self._lock:
  151. if self._stopped and self._pump_task is None:
  152. return None
  153. self._upstream_disconnect.set()
  154. if notify_subscribers:
  155. for queue in self._subscribers:
  156. try:
  157. queue.put_nowait(_UPSTREAM_GONE)
  158. except asyncio.QueueFull:
  159. pass
  160. self._subscribers.clear()
  161. pump_task = self._pump_task
  162. self._pump_task = None
  163. self._stopped = True
  164. if self._grace_task is not None and not self._grace_task.done():
  165. self._grace_task.cancel()
  166. self._grace_task = None
  167. return pump_task
  168. async def _await_pump_cancellation(self, pump_task: asyncio.Task | None) -> None:
  169. if pump_task is None or pump_task.done():
  170. return
  171. pump_task.cancel()
  172. try:
  173. await pump_task
  174. except (asyncio.CancelledError, Exception):
  175. # Pump exceptions are already logged inside _pump; swallow here so
  176. # teardown can never propagate a stray crash.
  177. pass
  178. async def _pump(self) -> None:
  179. """Drive the upstream generator and broadcast each chunk."""
  180. try:
  181. # Don't dial the printer until the broadcaster we're replacing has
  182. # closed its socket (#2521). Bounded so a wedged teardown degrades
  183. # to the old overlap behaviour rather than never producing a frame.
  184. predecessor = self._predecessor
  185. self._predecessor = None
  186. if predecessor is not None:
  187. try:
  188. await asyncio.wait_for(predecessor.wait_until_torn_down(), timeout=_TEARDOWN_WAIT_SECONDS)
  189. except asyncio.TimeoutError:
  190. logger.warning("Prior broadcaster %r didn't tear down in time; dialing anyway", self._key)
  191. async for chunk in self._factory(self._upstream_disconnect):
  192. # Snapshot subscribers under lock so we don't iterate a list
  193. # mutated by subscribe()/unsubscribe() while we are putting.
  194. # Remember the frame under the same lock so subscribe() can
  195. # prime a late joiner with a consistent last-chunk value (#2521).
  196. async with self._lock:
  197. self._last_chunk = chunk
  198. targets = list(self._subscribers)
  199. for queue in targets:
  200. try:
  201. queue.put_nowait(chunk)
  202. except asyncio.QueueFull:
  203. # Slow viewer — drop this frame for them. They'll catch
  204. # up on the next frame. Don't unsubscribe: a brief
  205. # browser stall shouldn't end the stream.
  206. pass
  207. except asyncio.CancelledError:
  208. raise
  209. except Exception:
  210. logger.exception("Camera fan-out pump crashed for %s", self._key)
  211. finally:
  212. # Pump is exiting — wake up any subscribers still hanging on get().
  213. async with self._lock:
  214. for queue in self._subscribers:
  215. try:
  216. queue.put_nowait(_UPSTREAM_GONE)
  217. except asyncio.QueueFull:
  218. pass
  219. # Global registry. Keyed by printer_id (as str) so a chamber-mode printer
  220. # and an RTSP-mode printer can never collide on the same key.
  221. _broadcasters: dict[str, MjpegBroadcaster] = {}
  222. _registry_lock = asyncio.Lock()
  223. async def get_or_create_broadcaster(key: str, factory: UpstreamFactory) -> MjpegBroadcaster:
  224. """Return the live broadcaster for `key`, creating one if needed.
  225. A broadcaster that has been stopped (force shutdown or grace timeout) is
  226. replaced with a fresh instance — the caller will subscribe to the new one.
  227. When replacing a stopped broadcaster, the fresh instance is handed it as a
  228. predecessor: its pump waits for the old socket to close before dialing, so
  229. a single-connection cam (chamber-image port 6000) never sees two sockets at
  230. once. Otherwise the printer keeps feeding the orphaned socket and starves
  231. the new one until its TCP keepalive reaps it, ~20 min later (#2521).
  232. """
  233. async with _registry_lock:
  234. existing = _broadcasters.get(key)
  235. if existing is not None and not existing.stopped:
  236. return existing
  237. # `existing` (if any) is stopped/tearing down — chain the new pump
  238. # behind its socket close.
  239. new_bc = MjpegBroadcaster(key, factory, predecessor=existing)
  240. _broadcasters[key] = new_bc
  241. return new_bc
  242. async def shutdown_broadcaster(key: str) -> bool:
  243. """Force-shutdown the broadcaster for `key`. Returns True if one was running."""
  244. async with _registry_lock:
  245. bc = _broadcasters.pop(key, None)
  246. if bc is None:
  247. return False
  248. await bc.force_shutdown()
  249. return True
  250. async def shutdown_all_broadcasters() -> None:
  251. """Tear down every broadcaster (for app shutdown)."""
  252. async with _registry_lock:
  253. bcs = list(_broadcasters.values())
  254. _broadcasters.clear()
  255. await asyncio.gather(*(bc.force_shutdown() for bc in bcs), return_exceptions=True)
  256. def active_broadcaster_keys() -> list[str]:
  257. """Snapshot of keys with a live (non-stopped) broadcaster. For diagnostics."""
  258. return [k for k, bc in _broadcasters.items() if not bc.stopped]
  259. def get_subscriber_count(key: str) -> int:
  260. """Return the number of live subscribers attached to ``key``, or 0.
  261. Used by ``/camera/stop`` to decide whether to force-shutdown the broadcaster
  262. or defer to natural cleanup. Other viewers (cam-wall tile, embedded viewer,
  263. popup window) all subscribe to the same broadcaster, so a force-shutdown
  264. triggered by one leaving viewer would kill the others' streams.
  265. """
  266. bc = _broadcasters.get(key)
  267. if bc is None or bc.stopped:
  268. return 0
  269. return bc.subscriber_count
  270. # ---------------------------------------------------------------------------
  271. # AsyncGenerator helper — turns a subscriber queue into an async generator
  272. # that yields MJPEG chunks until the upstream signals it's gone.
  273. # ---------------------------------------------------------------------------
  274. async def iter_subscriber(
  275. broadcaster: MjpegBroadcaster,
  276. queue: asyncio.Queue[bytes],
  277. *,
  278. is_disconnected: Callable[[], Awaitable[bool]] | None = None,
  279. on_unsubscribe: Callable[[int], None] | None = None,
  280. ) -> AsyncGenerator[bytes, None]:
  281. """Yield chunks from a subscriber queue until upstream ends or client leaves.
  282. Always unsubscribes from the broadcaster on exit, even on cancellation.
  283. The optional ``on_unsubscribe`` callback receives the post-unsubscribe
  284. subscriber count — useful for accurate detach-log lines that don't race
  285. with concurrent unsubscribes.
  286. """
  287. try:
  288. while True:
  289. try:
  290. chunk = await asyncio.wait_for(queue.get(), timeout=30.0)
  291. except asyncio.TimeoutError:
  292. # No frame in 30s — check whether the client is still there.
  293. # If yes, keep waiting; if no, bail out.
  294. if is_disconnected is not None and await is_disconnected():
  295. break
  296. continue
  297. if chunk == _UPSTREAM_GONE:
  298. break
  299. yield chunk
  300. if is_disconnected is not None and await is_disconnected():
  301. break
  302. finally:
  303. remaining = await broadcaster.unsubscribe(queue)
  304. if on_unsubscribe is not None:
  305. try:
  306. on_unsubscribe(remaining)
  307. except Exception:
  308. logger.exception("on_unsubscribe callback raised")