Files
bambuddy/backend/app/services/camera_light.py
T
maziggy 6954cff3ee Turn on the chamber light while the camera is in use
A printer kept dark between uses showed a black live view and sent black
notification photos (#1655). Settings → Camera can turn the light on for
all printers or for selected ones, for the live view, notification
photos, the finish photo and the snapshot API that Home Assistant uses,
and turns it off 15 seconds after the last use. It only turns off a
light it turned on, and switching the light by hand hands it back.
Snapshots wait an adjustable delay after the light comes on. Timelapse,
the Obico check and the in-print frame bank don't switch it, since they
would make it flash.

The camera card no longer lists every printer: external cameras are
added from a searchable printer picker and only those printers are
listed, so large farms get a short page.
2026-10-04 11:26:07 +02:00

200 lines
7.1 KiB
Python

"""Chamber light for the camera (#1655).
A printer kept dark between uses gives black live views and black snapshots.
The app setting ``camera_light_mode`` picks the printers this applies to:
"off", "all", or "selected" (those with ``Printer.camera_light_auto``). For
those, every camera use holds the light: the first
holder turns it on, and once the last one lets go it is turned off again after
a short grace, so a reconnecting viewer or the camera wall's periodic
snapshots don't make it flash.
Bambuddy only ever turns off a light it turned on itself. A light that was
already on stays on, and switching the light by hand hands it back to the user.
Layer timelapse, the Obico check and the in-print frame bank don't hold the
light: they capture all through a print, and the light would flash with every
frame.
"""
from __future__ import annotations
import asyncio
import logging
import time
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
logger = logging.getLogger(__name__)
# Long enough to cover a viewer reconnecting and the camera wall polling each
# tile every 8 seconds.
OFF_GRACE_SECONDS = 15.0
# Upper bound for camera_light_delay. The finish photo's producer budget
# (_FINISH_PHOTO_PRODUCER_WAIT_SECONDS in main.py) has about 8 seconds to spare
# beyond its plate settle and worst-case grab; a longer wait would push the
# producer past it and into the #1790 race with the fallback grab.
MAX_DELAY_SECONDS = 5.0
# The two app settings are read at most this often: the camera wall asks for a
# snapshot of every tile every 8 seconds, which on a large farm is many reads a
# second. A saved change applies at once through invalidate_settings().
_SETTINGS_TTL_SECONDS = 5.0
_MODES = frozenset({"off", "all", "selected"})
# A light we switched on can still read as off until the printer reports back.
# Within this window we trust our own command instead of sending another.
_REPORT_LAG_SECONDS = 5.0
_holders: dict[int, int] = {}
_lit_at: dict[int, float] = {} # printers whose light we turned on, and when
_off_tasks: dict[int, asyncio.Task] = {}
_settings_cache: tuple[float, str, float] | None = None # (read at, mode, delay)
async def _read_settings() -> tuple[str, float]:
from backend.app.api.routes.settings import get_setting
from backend.app.core import database
async with database.async_session() as db:
mode = await get_setting(db, "camera_light_mode")
delay = await get_setting(db, "camera_light_delay")
mode = mode if mode in _MODES else "off"
try:
seconds = float(delay) if delay is not None else 2.0
except ValueError:
seconds = 2.0
return mode, min(max(0.0, seconds), MAX_DELAY_SECONDS)
async def _light_settings() -> tuple[str, float]:
"""The light mode and snapshot delay, cached briefly. Off when unreadable."""
global _settings_cache
now = time.monotonic()
if _settings_cache is not None and now - _settings_cache[0] < _SETTINGS_TTL_SECONDS:
return _settings_cache[1], _settings_cache[2]
try:
mode, delay = await _read_settings()
except Exception as e:
# The light must never cost the picture, and an unreadable setting
# must not switch lights it was never asked to switch.
logger.debug("[CAMERA-LIGHT] Could not read the light settings: %s", e)
return "off", 2.0
_settings_cache = (now, mode, delay)
return mode, delay
def invalidate_settings() -> None:
"""Make the next camera use read the light settings again."""
global _settings_cache
_settings_cache = None
def _get_client(printer_id: int):
from backend.app.services.printer_manager import printer_manager
return printer_manager.get_client(printer_id)
def _cancel_off(printer_id: int) -> None:
task = _off_tasks.pop(printer_id, None)
if task is not None and not task.done():
task.cancel()
def _acquire(printer_id: int) -> float | None:
"""Hold the light. Returns when it was turned on, or None if it was already on.
The hold is counted before anything can fail, so the caller always releases.
"""
_holders[printer_id] = _holders.get(printer_id, 0) + 1
_cancel_off(printer_id)
client = _get_client(printer_id)
if client is None or client.state is None or client.state.chamber_light:
return None
lit_at = _lit_at.get(printer_id)
if lit_at is not None and time.monotonic() - lit_at < _REPORT_LAG_SECONDS:
return lit_at # our "on" hasn't been reported yet
if not client.set_chamber_light(True):
return None
logger.info("[CAMERA-LIGHT] Turned on chamber light for printer %s", printer_id)
now = time.monotonic()
_lit_at[printer_id] = now
return now
def _release(printer_id: int) -> None:
count = _holders.get(printer_id, 0) - 1
if count > 0:
_holders[printer_id] = count
return
_holders.pop(printer_id, None)
if printer_id in _lit_at:
_cancel_off(printer_id)
try:
_off_tasks[printer_id] = asyncio.create_task(_turn_off_later(printer_id))
except RuntimeError:
# No running loop: a stream finalised during shutdown. Nothing can
# switch the light any more.
pass
async def _turn_off_later(printer_id: int) -> None:
try:
await asyncio.sleep(OFF_GRACE_SECONDS)
except asyncio.CancelledError:
return
_off_tasks.pop(printer_id, None)
if _holders.get(printer_id) or _lit_at.pop(printer_id, None) is None:
return
client = _get_client(printer_id)
if client is None:
return
try:
if client.set_chamber_light(False):
logger.info("[CAMERA-LIGHT] Turned chamber light back off for printer %s", printer_id)
except Exception as e:
logger.warning("[CAMERA-LIGHT] Failed to turn chamber light off for printer %s: %s", printer_id, e)
def hand_back(printer_id: int) -> None:
"""The user switched the light: it is theirs now, so never turn it off."""
_lit_at.pop(printer_id, None)
_cancel_off(printer_id)
@asynccontextmanager
async def camera_light(printer, *, wait: bool = True) -> AsyncIterator[None]:
"""Keep the printer's chamber light on while the camera is in use.
``wait`` makes a snapshot wait the ``camera_light_delay`` setting after
the light came on, so the light (and any light synced to it) has time to
reach the picture. A live view doesn't wait.
"""
if printer is None:
yield
return
mode, delay = await _light_settings()
if mode == "off" or (mode == "selected" and getattr(printer, "camera_light_auto", False) is not True):
yield
return
printer_id = printer.id
try:
try:
lit_at = _acquire(printer_id)
except Exception as e:
# A light we can't switch must never cost the picture.
logger.warning("[CAMERA-LIGHT] Failed to turn chamber light on for printer %s: %s", printer_id, e)
lit_at = None
if wait and lit_at is not None:
remaining = delay - (time.monotonic() - lit_at)
if remaining > 0:
await asyncio.sleep(remaining)
yield
finally:
_release(printer_id)