camera_light.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  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. logger = logging.getLogger(__name__)
  22. # Long enough to cover a viewer reconnecting and the camera wall polling each
  23. # tile every 8 seconds.
  24. OFF_GRACE_SECONDS = 15.0
  25. # Upper bound for camera_light_delay. The finish photo's producer budget
  26. # (_FINISH_PHOTO_PRODUCER_WAIT_SECONDS in main.py) has about 8 seconds to spare
  27. # beyond its plate settle and worst-case grab; a longer wait would push the
  28. # producer past it and into the #1790 race with the fallback grab.
  29. MAX_DELAY_SECONDS = 5.0
  30. # The two app settings are read at most this often: the camera wall asks for a
  31. # snapshot of every tile every 8 seconds, which on a large farm is many reads a
  32. # second. A saved change applies at once through invalidate_settings().
  33. _SETTINGS_TTL_SECONDS = 5.0
  34. _MODES = frozenset({"off", "all", "selected"})
  35. # A light we switched on can still read as off until the printer reports back.
  36. # Within this window we trust our own command instead of sending another.
  37. _REPORT_LAG_SECONDS = 5.0
  38. _holders: dict[int, int] = {}
  39. _lit_at: dict[int, float] = {} # printers whose light we turned on, and when
  40. _off_tasks: dict[int, asyncio.Task] = {}
  41. _settings_cache: tuple[float, str, float] | None = None # (read at, mode, delay)
  42. async def _read_settings() -> tuple[str, float]:
  43. from backend.app.api.routes.settings import get_setting
  44. from backend.app.core import database
  45. async with database.async_session() as db:
  46. mode = await get_setting(db, "camera_light_mode")
  47. delay = await get_setting(db, "camera_light_delay")
  48. mode = mode if mode in _MODES else "off"
  49. try:
  50. seconds = float(delay) if delay is not None else 2.0
  51. except ValueError:
  52. seconds = 2.0
  53. return mode, min(max(0.0, seconds), MAX_DELAY_SECONDS)
  54. async def _light_settings() -> tuple[str, float]:
  55. """The light mode and snapshot delay, cached briefly. Off when unreadable."""
  56. global _settings_cache
  57. now = time.monotonic()
  58. if _settings_cache is not None and now - _settings_cache[0] < _SETTINGS_TTL_SECONDS:
  59. return _settings_cache[1], _settings_cache[2]
  60. try:
  61. mode, delay = await _read_settings()
  62. except Exception as e:
  63. # The light must never cost the picture, and an unreadable setting
  64. # must not switch lights it was never asked to switch.
  65. logger.debug("[CAMERA-LIGHT] Could not read the light settings: %s", e)
  66. return "off", 2.0
  67. _settings_cache = (now, mode, delay)
  68. return mode, delay
  69. def invalidate_settings() -> None:
  70. """Make the next camera use read the light settings again."""
  71. global _settings_cache
  72. _settings_cache = None
  73. def _get_client(printer_id: int):
  74. from backend.app.services.printer_manager import printer_manager
  75. return printer_manager.get_client(printer_id)
  76. def _cancel_off(printer_id: int) -> None:
  77. task = _off_tasks.pop(printer_id, None)
  78. if task is not None and not task.done():
  79. task.cancel()
  80. def _acquire(printer_id: int) -> float | None:
  81. """Hold the light. Returns when it was turned on, or None if it was already on.
  82. The hold is counted before anything can fail, so the caller always releases.
  83. """
  84. _holders[printer_id] = _holders.get(printer_id, 0) + 1
  85. _cancel_off(printer_id)
  86. client = _get_client(printer_id)
  87. if client is None or client.state is None or client.state.chamber_light:
  88. return None
  89. lit_at = _lit_at.get(printer_id)
  90. if lit_at is not None and time.monotonic() - lit_at < _REPORT_LAG_SECONDS:
  91. return lit_at # our "on" hasn't been reported yet
  92. if not client.set_chamber_light(True):
  93. return None
  94. logger.info("[CAMERA-LIGHT] Turned on chamber light for printer %s", printer_id)
  95. now = time.monotonic()
  96. _lit_at[printer_id] = now
  97. return now
  98. def _release(printer_id: int) -> None:
  99. count = _holders.get(printer_id, 0) - 1
  100. if count > 0:
  101. _holders[printer_id] = count
  102. return
  103. _holders.pop(printer_id, None)
  104. if printer_id in _lit_at:
  105. _cancel_off(printer_id)
  106. try:
  107. _off_tasks[printer_id] = asyncio.create_task(_turn_off_later(printer_id))
  108. except RuntimeError:
  109. # No running loop: a stream finalised during shutdown. Nothing can
  110. # switch the light any more.
  111. pass
  112. async def _turn_off_later(printer_id: int) -> None:
  113. try:
  114. await asyncio.sleep(OFF_GRACE_SECONDS)
  115. except asyncio.CancelledError:
  116. return
  117. _off_tasks.pop(printer_id, None)
  118. if _holders.get(printer_id) or _lit_at.pop(printer_id, None) is None:
  119. return
  120. client = _get_client(printer_id)
  121. if client is None:
  122. return
  123. try:
  124. if client.set_chamber_light(False):
  125. logger.info("[CAMERA-LIGHT] Turned chamber light back off for printer %s", printer_id)
  126. except Exception as e:
  127. logger.warning("[CAMERA-LIGHT] Failed to turn chamber light off for printer %s: %s", printer_id, e)
  128. def hand_back(printer_id: int) -> None:
  129. """The user switched the light: it is theirs now, so never turn it off."""
  130. _lit_at.pop(printer_id, None)
  131. _cancel_off(printer_id)
  132. @asynccontextmanager
  133. async def camera_light(printer, *, wait: bool = True) -> AsyncIterator[None]:
  134. """Keep the printer's chamber light on while the camera is in use.
  135. ``wait`` makes a snapshot wait the ``camera_light_delay`` setting after
  136. the light came on, so the light (and any light synced to it) has time to
  137. reach the picture. A live view doesn't wait.
  138. """
  139. if printer is None:
  140. yield
  141. return
  142. mode, delay = await _light_settings()
  143. if mode == "off" or (mode == "selected" and getattr(printer, "camera_light_auto", False) is not True):
  144. yield
  145. return
  146. printer_id = printer.id
  147. try:
  148. try:
  149. lit_at = _acquire(printer_id)
  150. except Exception as e:
  151. # A light we can't switch must never cost the picture.
  152. logger.warning("[CAMERA-LIGHT] Failed to turn chamber light on for printer %s: %s", printer_id, e)
  153. lit_at = None
  154. if wait and lit_at is not None:
  155. remaining = delay - (time.monotonic() - lit_at)
  156. if remaining > 0:
  157. await asyncio.sleep(remaining)
  158. yield
  159. finally:
  160. _release(printer_id)