camera_light.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. """Chamber light for the camera (#1655).
  2. A printer kept dark between uses gives black live views and black snapshots.
  3. The app setting ``camera_light_mode`` picks the printers this applies to:
  4. "off", "all", or "selected" (those with ``Printer.camera_light_auto``). For
  5. those, every camera use holds the light: the first
  6. holder turns it on, and once the last one lets go it is turned off again after
  7. a short grace, so a reconnecting viewer or the camera wall's periodic
  8. snapshots don't make it flash.
  9. Bambuddy only ever turns off a light it turned on itself. A light that was
  10. already on stays on, and switching the light by hand hands it back to the user.
  11. Layer timelapse, the Obico check and the in-print frame bank don't hold the
  12. light: they capture all through a print, and the light would flash with every
  13. frame.
  14. """
  15. from __future__ import annotations
  16. import asyncio
  17. import logging
  18. import time
  19. from collections.abc import AsyncIterator
  20. from contextlib import asynccontextmanager
  21. import anyio
  22. logger = logging.getLogger(__name__)
  23. # Long enough to cover a viewer reconnecting and the camera wall polling each
  24. # tile every 8 seconds.
  25. OFF_GRACE_SECONDS = 15.0
  26. # Upper bound for camera_light_delay. The finish photo's producer budget
  27. # (_FINISH_PHOTO_PRODUCER_WAIT_SECONDS in main.py) has about 8 seconds to spare
  28. # beyond its plate settle and worst-case grab; a longer wait would push the
  29. # producer past it and into the #1790 race with the fallback grab.
  30. MAX_DELAY_SECONDS = 5.0
  31. # The two app settings are read at most this often: the camera wall asks for a
  32. # snapshot of every tile every 8 seconds, which on a large farm is many reads a
  33. # second. A saved change applies at once through invalidate_settings().
  34. _SETTINGS_TTL_SECONDS = 5.0
  35. _MODES = frozenset({"off", "all", "selected"})
  36. # A light we switched on can still read as off until the printer reports back.
  37. # Within this window we trust our own command instead of sending another.
  38. _REPORT_LAG_SECONDS = 5.0
  39. _holders: dict[int, int] = {}
  40. _lit_at: dict[int, float] = {} # printers whose light we turned on, and when
  41. _off_tasks: dict[int, asyncio.Task] = {}
  42. _settings_cache: tuple[float, str, float] | None = None # (read at, mode, delay)
  43. async def _read_settings() -> tuple[str, float]:
  44. from backend.app.api.routes.settings import get_setting
  45. from backend.app.core import database
  46. # Shielded (#3515): a viewer leaving cancels the stream, and a read
  47. # cancelled halfway costs its pooled connection. The two short reads and
  48. # the session's close run to the end first.
  49. with anyio.CancelScope(shield=True):
  50. async with database.async_session() as db:
  51. mode = await get_setting(db, "camera_light_mode")
  52. delay = await get_setting(db, "camera_light_delay")
  53. mode = mode if mode in _MODES else "off"
  54. try:
  55. seconds = float(delay) if delay is not None else 2.0
  56. except ValueError:
  57. seconds = 2.0
  58. return mode, min(max(0.0, seconds), MAX_DELAY_SECONDS)
  59. async def _light_settings() -> tuple[str, float]:
  60. """The light mode and snapshot delay, cached briefly. Off when unreadable."""
  61. global _settings_cache
  62. now = time.monotonic()
  63. if _settings_cache is not None and now - _settings_cache[0] < _SETTINGS_TTL_SECONDS:
  64. return _settings_cache[1], _settings_cache[2]
  65. try:
  66. mode, delay = await _read_settings()
  67. except Exception as e:
  68. # The light must never cost the picture, and an unreadable setting
  69. # must not switch lights it was never asked to switch.
  70. logger.debug("[CAMERA-LIGHT] Could not read the light settings: %s", e)
  71. return "off", 2.0
  72. _settings_cache = (now, mode, delay)
  73. return mode, delay
  74. def invalidate_settings() -> None:
  75. """Make the next camera use read the light settings again."""
  76. global _settings_cache
  77. _settings_cache = None
  78. def _get_client(printer_id: int):
  79. from backend.app.services.printer_manager import printer_manager
  80. return printer_manager.get_client(printer_id)
  81. def _cancel_off(printer_id: int) -> None:
  82. task = _off_tasks.pop(printer_id, None)
  83. if task is not None and not task.done():
  84. task.cancel()
  85. def _acquire(printer_id: int) -> float | None:
  86. """Hold the light. Returns when it was turned on, or None if it was already on.
  87. The hold is counted before anything can fail, so the caller always releases.
  88. """
  89. _holders[printer_id] = _holders.get(printer_id, 0) + 1
  90. _cancel_off(printer_id)
  91. client = _get_client(printer_id)
  92. if client is None or client.state is None or client.state.chamber_light:
  93. return None
  94. lit_at = _lit_at.get(printer_id)
  95. if lit_at is not None and time.monotonic() - lit_at < _REPORT_LAG_SECONDS:
  96. return lit_at # our "on" hasn't been reported yet
  97. if not client.set_chamber_light(True):
  98. return None
  99. logger.info("[CAMERA-LIGHT] Turned on chamber light for printer %s", printer_id)
  100. now = time.monotonic()
  101. _lit_at[printer_id] = now
  102. return now
  103. def _release(printer_id: int) -> None:
  104. count = _holders.get(printer_id, 0) - 1
  105. if count > 0:
  106. _holders[printer_id] = count
  107. return
  108. _holders.pop(printer_id, None)
  109. if printer_id in _lit_at:
  110. _cancel_off(printer_id)
  111. try:
  112. _off_tasks[printer_id] = asyncio.create_task(_turn_off_later(printer_id))
  113. except RuntimeError:
  114. # No running loop: a stream finalised during shutdown. Nothing can
  115. # switch the light any more.
  116. pass
  117. async def _turn_off_later(printer_id: int) -> None:
  118. try:
  119. await asyncio.sleep(OFF_GRACE_SECONDS)
  120. except asyncio.CancelledError:
  121. return
  122. _off_tasks.pop(printer_id, None)
  123. if _holders.get(printer_id) or _lit_at.pop(printer_id, None) is None:
  124. return
  125. client = _get_client(printer_id)
  126. if client is None:
  127. return
  128. try:
  129. if client.set_chamber_light(False):
  130. logger.info("[CAMERA-LIGHT] Turned chamber light back off for printer %s", printer_id)
  131. except Exception as e:
  132. logger.warning("[CAMERA-LIGHT] Failed to turn chamber light off for printer %s: %s", printer_id, e)
  133. def hand_back(printer_id: int) -> None:
  134. """The user switched the light: it is theirs now, so never turn it off."""
  135. _lit_at.pop(printer_id, None)
  136. _cancel_off(printer_id)
  137. @asynccontextmanager
  138. async def camera_light(
  139. printer, *, wait: bool = True, any_mode: bool = False, settle: float | None = None
  140. ) -> AsyncIterator[None]:
  141. """Keep the printer's chamber light on while the camera is in use.
  142. ``wait`` makes a snapshot wait the ``camera_light_delay`` setting after
  143. the light came on, so the light (and any light synced to it) has time to
  144. reach the picture. A live view doesn't wait.
  145. ``any_mode`` holds the light whatever the light mode says, and ``settle``
  146. replaces the delay setting: the plate check needs a lit plate on every
  147. printer, and holding the light here rather than switching it itself keeps
  148. it from switching the light off under a viewer who came meanwhile (SP-C11).
  149. """
  150. if printer is None:
  151. yield
  152. return
  153. mode, delay = await _light_settings()
  154. if settle is not None:
  155. delay = settle
  156. if not any_mode and (
  157. mode == "off" or (mode == "selected" and getattr(printer, "camera_light_auto", False) is not True)
  158. ):
  159. yield
  160. return
  161. printer_id = printer.id
  162. try:
  163. try:
  164. lit_at = _acquire(printer_id)
  165. except Exception as e:
  166. # A light we can't switch must never cost the picture.
  167. logger.warning("[CAMERA-LIGHT] Failed to turn chamber light on for printer %s: %s", printer_id, e)
  168. lit_at = None
  169. if wait and lit_at is not None:
  170. remaining = delay - (time.monotonic() - lit_at)
  171. if remaining > 0:
  172. await asyncio.sleep(remaining)
  173. yield
  174. finally:
  175. _release(printer_id)