Files
bambuddy/backend/tests/unit/services/test_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

310 lines
10 KiB
Python

"""Chamber light for the camera (#1655).
The light goes on for a camera use when the light mode covers the printer, stays on
while anything still uses the camera, and goes off after a grace only if
Bambuddy turned it on.
"""
import asyncio
import time
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from backend.app.services import camera_light as svc
from backend.app.services.camera_light import camera_light
pytestmark = pytest.mark.unit
# The autouse fixture stubs the settings read; this is the real one.
_real_read_settings = svc._read_settings
GRACE = 0.05
class LightSettings:
"""Stands in for reading the two app settings; counts the reads."""
def __init__(self):
self.mode = "all"
self.delay = 0.0
self.reads = 0
async def __call__(self):
self.reads += 1
return self.mode, self.delay
@pytest.fixture(autouse=True)
def settings(monkeypatch):
monkeypatch.setattr(svc, "_holders", {})
monkeypatch.setattr(svc, "_lit_at", {})
off_tasks: dict = {}
monkeypatch.setattr(svc, "_off_tasks", off_tasks)
monkeypatch.setattr(svc, "OFF_GRACE_SECONDS", GRACE)
light = LightSettings()
monkeypatch.setattr(svc, "_read_settings", light)
monkeypatch.setattr(svc, "_settings_cache", None)
yield light
for task in off_tasks.values():
task.cancel()
class FakeClient:
"""A printer whose light state follows its commands, like a real report would."""
def __init__(self, light_on: bool = False, reports: bool = True):
self.state = SimpleNamespace(chamber_light=light_on)
self.calls: list[bool] = []
self._reports = reports
def set_chamber_light(self, on: bool) -> bool:
self.calls.append(on)
if self._reports:
self.state.chamber_light = on
return True
def _printer(selected=False, printer_id=1):
return SimpleNamespace(id=printer_id, camera_light_auto=selected)
@pytest.fixture
def client(monkeypatch):
fake = FakeClient()
monkeypatch.setattr(svc, "_get_client", lambda _pid: fake)
return fake
async def _past_grace():
await asyncio.sleep(GRACE * 3)
class TestWhichPrinters:
@pytest.mark.asyncio
async def test_mode_off_never_switches(self, client, settings):
settings.mode = "off"
async with camera_light(_printer(selected=True)):
pass
await _past_grace()
assert client.calls == []
@pytest.mark.asyncio
async def test_mode_all_switches_every_printer(self, client, settings):
settings.mode = "all"
async with camera_light(_printer(selected=False)):
pass
assert client.calls == [True]
@pytest.mark.asyncio
async def test_mode_selected_switches_only_picked_printers(self, client, settings):
settings.mode = "selected"
async with camera_light(_printer(selected=False)):
pass
assert client.calls == []
async with camera_light(_printer(selected=True)):
pass
assert client.calls == [True]
@pytest.mark.asyncio
async def test_no_printer_is_a_no_op(self, client):
async with camera_light(None):
pass
assert client.calls == []
class TestTurnsOnAndOff:
@pytest.mark.asyncio
async def test_a_dark_printer_is_lit_for_the_picture_and_turned_off_after(self, client):
async with camera_light(_printer()):
assert client.calls == [True]
assert client.calls == [True] # not yet: the grace runs first
await _past_grace()
assert client.calls == [True, False]
@pytest.mark.asyncio
async def test_a_light_that_was_already_on_is_left_alone(self, client, settings):
client.state.chamber_light = True
settings.delay = 5
async with camera_light(_printer()):
pass
await _past_grace()
assert client.calls == []
@pytest.mark.asyncio
async def test_the_light_stays_on_until_the_last_user_lets_go(self, client):
printer = _printer()
viewer = camera_light(printer, wait=False)
await viewer.__aenter__()
async with camera_light(printer):
pass
await _past_grace()
assert client.calls == [True] # the viewer still watches
await viewer.__aexit__(None, None, None)
await _past_grace()
assert client.calls == [True, False]
@pytest.mark.asyncio
async def test_coming_back_within_the_grace_keeps_the_light_on(self, client):
# The camera wall polls each tile every 8 seconds; it must not flash.
printer = _printer()
async with camera_light(printer):
pass
async with camera_light(printer):
pass
assert client.calls == [True]
await _past_grace()
assert client.calls == [True, False]
class TestTheDelay:
@pytest.mark.asyncio
async def test_a_snapshot_waits_after_turning_the_light_on(self, client, settings):
settings.delay = 0.2
start = time.monotonic()
async with camera_light(_printer()):
waited = time.monotonic() - start
assert waited >= 0.2
@pytest.mark.asyncio
async def test_a_live_view_does_not_wait_either_way(self, client, settings):
settings.delay = 5
start = time.monotonic()
async with camera_light(_printer(), wait=False):
waited = time.monotonic() - start
assert waited < 1
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_a_second_snapshot_waits_only_for_what_is_left_and_sends_nothing(self, monkeypatch, settings):
# The printer hasn't reported the light yet: the second picture trusts
# our own command instead of sending another, and still waits for it.
slow = FakeClient(reports=False)
monkeypatch.setattr(svc, "_get_client", lambda _pid: slow)
settings.delay = 0.3
printer = _printer()
first = asyncio.create_task(_hold(printer))
await asyncio.sleep(0.1)
start = time.monotonic()
async with camera_light(printer):
waited = time.monotonic() - start
await first
assert slow.calls == [True]
assert 0.1 <= waited < 0.3
async def _hold(printer):
async with camera_light(printer):
pass
class TestTheUserDecides:
@pytest.mark.asyncio
async def test_switching_the_light_by_hand_keeps_it_on(self, client):
printer = _printer()
async with camera_light(printer):
svc.hand_back(printer.id)
await _past_grace()
assert client.calls == [True]
@pytest.mark.asyncio
async def test_a_light_switched_off_at_the_printer_comes_back_for_the_next_picture(self, client, monkeypatch):
monkeypatch.setattr(svc, "_REPORT_LAG_SECONDS", 0.05)
printer = _printer()
viewer = camera_light(printer, wait=False)
await viewer.__aenter__()
client.state.chamber_light = False # switched off on the printer's screen
await asyncio.sleep(0.1)
async with camera_light(printer):
pass
await viewer.__aexit__(None, None, None)
await _past_grace()
assert client.calls == [True, True, False]
class TestFailuresNeverCostThePicture:
@pytest.mark.asyncio
async def test_a_disconnected_printer_still_gets_its_capture(self, monkeypatch, settings):
monkeypatch.setattr(svc, "_get_client", lambda _pid: None)
settings.delay = 5
ran = False
async with camera_light(_printer()):
ran = True
assert ran
assert svc._holders == {}
@pytest.mark.asyncio
async def test_a_failing_light_command_still_gets_its_capture(self, monkeypatch, settings):
broken = MagicMock()
broken.state = SimpleNamespace(chamber_light=False)
broken.set_chamber_light.side_effect = RuntimeError("mqtt down")
monkeypatch.setattr(svc, "_get_client", lambda _pid: broken)
settings.delay = 5
ran = False
async with camera_light(_printer()):
ran = True
assert ran
assert svc._holders == {}
@pytest.mark.asyncio
async def test_a_capture_that_raises_still_lets_go_of_the_light(self, client):
with pytest.raises(ValueError):
async with camera_light(_printer()):
raise ValueError("capture failed")
await _past_grace()
assert client.calls == [True, False]
assert svc._holders == {}
class TestTheSettings:
@pytest.mark.asyncio
async def test_settings_are_read_once_then_cached_until_saved(self, client, settings):
async with camera_light(_printer()):
pass
async with camera_light(_printer()):
pass
assert settings.reads == 1
svc.invalidate_settings()
async with camera_light(_printer()):
pass
assert settings.reads == 2
@pytest.mark.asyncio
async def test_unreadable_settings_switch_nothing(self, client, monkeypatch):
async def _broken():
raise RuntimeError("database gone")
monkeypatch.setattr(svc, "_read_settings", _broken)
async with camera_light(_printer(selected=True)):
pass
assert client.calls == []
@pytest.mark.asyncio
@pytest.mark.parametrize(
("stored_mode", "stored_delay", "expected"),
[
("all", "1.5", ("all", 1.5)),
("selected", None, ("selected", 2.0)),
(None, None, ("off", 2.0)), # never saved
("sometimes", "x", ("off", 2.0)), # garbage reads as off
("all", "30", ("all", 5.0)), # never past the finish photo's budget
("all", "-3", ("all", 0.0)),
],
)
async def test_stored_values_are_parsed_safely(self, monkeypatch, stored_mode, stored_delay, expected):
from contextlib import asynccontextmanager
stored = {"camera_light_mode": stored_mode, "camera_light_delay": stored_delay}
async def _get_setting(_db, key):
return stored[key]
@asynccontextmanager
async def _session():
yield object()
monkeypatch.setattr("backend.app.api.routes.settings.get_setting", _get_setting)
monkeypatch.setattr("backend.app.core.database.async_session", _session)
assert await _real_read_settings() == expected