mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-07 14:41:24 +02:00
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.
226 lines
9.1 KiB
Python
226 lines
9.1 KiB
Python
"""Chamber light for the camera (#1655): the settings, and the routes that hold the light.
|
|
|
|
The light mode and delay are set through the settings API here, so the service
|
|
reads them back from the database exactly as it does in production."""
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
from backend.app.services import camera_light as light_svc
|
|
|
|
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
|
|
|
|
|
@pytest.fixture
|
|
def light_client(monkeypatch):
|
|
client = MagicMock()
|
|
client.state = SimpleNamespace(chamber_light=False)
|
|
client.set_chamber_light.return_value = True
|
|
monkeypatch.setattr(light_svc, "_get_client", lambda _pid: client)
|
|
monkeypatch.setattr(light_svc, "_holders", {})
|
|
monkeypatch.setattr(light_svc, "_lit_at", {})
|
|
monkeypatch.setattr(light_svc, "_settings_cache", None)
|
|
off_tasks: dict = {}
|
|
monkeypatch.setattr(light_svc, "_off_tasks", off_tasks)
|
|
yield client
|
|
for task in off_tasks.values():
|
|
task.cancel()
|
|
|
|
|
|
async def _set_light(async_client: AsyncClient, **values):
|
|
response = await async_client.patch("/api/v1/settings/", json=values)
|
|
assert response.status_code == 200, response.text
|
|
return response.json()
|
|
|
|
|
|
async def test_the_light_is_off_by_default(async_client: AsyncClient, printer_factory):
|
|
printer = await printer_factory()
|
|
settings = (await async_client.get("/api/v1/settings/")).json()
|
|
assert settings["camera_light_mode"] == "off"
|
|
assert settings["camera_light_delay"] == 2.0
|
|
assert (await async_client.get(f"/api/v1/printers/{printer.id}")).json()["camera_light_auto"] is False
|
|
|
|
|
|
async def test_the_mode_and_delay_are_saved(async_client: AsyncClient):
|
|
saved = await _set_light(async_client, camera_light_mode="selected", camera_light_delay=1.5)
|
|
assert saved["camera_light_mode"] == "selected"
|
|
assert saved["camera_light_delay"] == 1.5
|
|
|
|
|
|
async def test_a_printer_is_picked_for_the_selected_mode(async_client: AsyncClient, printer_factory):
|
|
printer = await printer_factory()
|
|
response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"camera_light_auto": True})
|
|
assert response.status_code == 200
|
|
assert response.json()["camera_light_auto"] is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"values",
|
|
[
|
|
{"camera_light_mode": "sometimes"},
|
|
{"camera_light_delay": -1},
|
|
{"camera_light_delay": 5.5},
|
|
{"camera_light_delay": None},
|
|
],
|
|
)
|
|
async def test_bad_light_settings_are_refused(async_client: AsyncClient, values):
|
|
response = await async_client.patch("/api/v1/settings/", json=values)
|
|
assert response.status_code == 422
|
|
|
|
|
|
async def test_a_snapshot_is_taken_with_the_light_on(async_client: AsyncClient, printer_factory, light_client):
|
|
"""Home Assistant and other automations take their pictures here."""
|
|
await _set_light(async_client, camera_light_mode="all", camera_light_delay=0)
|
|
printer = await printer_factory()
|
|
fake_jpeg = b"\xff\xd8\xff\xe0snap"
|
|
seen = {}
|
|
|
|
async def _snapshot(printer_id, _printer):
|
|
seen["calls"] = list(light_client.set_chamber_light.call_args_list)
|
|
from fastapi import Response
|
|
|
|
return Response(content=fake_jpeg, media_type="image/jpeg")
|
|
|
|
with patch("backend.app.api.routes.camera._snapshot_response", _snapshot):
|
|
response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
|
|
|
|
assert response.status_code == 200
|
|
assert response.content == fake_jpeg
|
|
assert [c.args for c in seen["calls"]] == [(True,)]
|
|
|
|
|
|
async def test_a_snapshot_leaves_the_light_alone_by_default(async_client: AsyncClient, printer_factory, light_client):
|
|
printer = await printer_factory()
|
|
|
|
async def _snapshot(printer_id, _printer):
|
|
from fastapi import Response
|
|
|
|
return Response(content=b"\xff\xd8", media_type="image/jpeg")
|
|
|
|
with patch("backend.app.api.routes.camera._snapshot_response", _snapshot):
|
|
await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
|
|
|
|
light_client.set_chamber_light.assert_not_called()
|
|
|
|
|
|
async def test_selected_mode_lights_only_the_picked_printers(async_client: AsyncClient, printer_factory, light_client):
|
|
await _set_light(async_client, camera_light_mode="selected", camera_light_delay=0)
|
|
picked = await printer_factory(camera_light_auto=True)
|
|
other = await printer_factory(serial_number="00M09A999999999")
|
|
|
|
async def _snapshot(printer_id, _printer):
|
|
from fastapi import Response
|
|
|
|
return Response(content=b"\xff\xd8", media_type="image/jpeg")
|
|
|
|
with patch("backend.app.api.routes.camera._snapshot_response", _snapshot):
|
|
await async_client.get(f"/api/v1/printers/{other.id}/camera/snapshot")
|
|
light_client.set_chamber_light.assert_not_called()
|
|
await async_client.get(f"/api/v1/printers/{picked.id}/camera/snapshot")
|
|
light_client.set_chamber_light.assert_called_once_with(True)
|
|
|
|
|
|
async def test_saving_the_mode_applies_at_once(async_client: AsyncClient, printer_factory, light_client):
|
|
"""The service caches the settings; saving them must not wait out the cache."""
|
|
printer = await printer_factory()
|
|
|
|
async def _snapshot(printer_id, _printer):
|
|
from fastapi import Response
|
|
|
|
return Response(content=b"\xff\xd8", media_type="image/jpeg")
|
|
|
|
with patch("backend.app.api.routes.camera._snapshot_response", _snapshot):
|
|
await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot") # caches "off"
|
|
await _set_light(async_client, camera_light_mode="all", camera_light_delay=0)
|
|
await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
|
|
light_client.set_chamber_light.assert_called_once_with(True)
|
|
|
|
|
|
async def test_switching_the_light_by_hand_hands_it_back(async_client: AsyncClient, printer_factory):
|
|
printer = await printer_factory()
|
|
light_svc._lit_at[printer.id] = 0.0
|
|
client = MagicMock()
|
|
client.set_chamber_light.return_value = True
|
|
try:
|
|
with patch("backend.app.api.routes.printers.printer_manager") as pm:
|
|
pm.get_client.return_value = client
|
|
response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
|
|
assert response.status_code == 200
|
|
assert printer.id not in light_svc._lit_at
|
|
finally:
|
|
light_svc._lit_at.pop(printer.id, None)
|
|
|
|
|
|
async def test_the_live_view_holds_the_light_while_it_streams(async_client: AsyncClient, printer_factory, light_client):
|
|
await _set_light(async_client, camera_light_mode="all", camera_light_delay=5)
|
|
printer = await printer_factory()
|
|
during = {}
|
|
|
|
async def _iter_subscriber(*_args, **_kwargs):
|
|
during["calls"] = [c.args for c in light_client.set_chamber_light.call_args_list]
|
|
yield b"--frame\r\n"
|
|
|
|
broadcaster = MagicMock(subscriber_count=1)
|
|
|
|
async def _subscribe():
|
|
return MagicMock()
|
|
|
|
broadcaster.subscribe = _subscribe
|
|
|
|
async def _get_broadcaster(_key, _factory):
|
|
return broadcaster
|
|
|
|
with (
|
|
patch("backend.app.api.routes.camera.get_or_create_broadcaster", _get_broadcaster),
|
|
patch("backend.app.api.routes.camera.iter_subscriber", _iter_subscriber),
|
|
):
|
|
response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/stream")
|
|
|
|
assert response.status_code == 200
|
|
assert during["calls"] == [(True,)]
|
|
# The viewer left: the light goes off after the grace, not at once.
|
|
assert printer.id in light_svc._off_tasks
|
|
|
|
|
|
async def test_an_external_camera_view_holds_the_light_too(async_client: AsyncClient, printer_factory, light_client):
|
|
await _set_light(async_client, camera_light_mode="selected")
|
|
printer = await printer_factory(
|
|
camera_light_auto=True,
|
|
external_camera_enabled=True,
|
|
external_camera_url="http://192.0.2.50/mjpeg",
|
|
external_camera_type="mjpeg",
|
|
)
|
|
during = {}
|
|
|
|
async def _stream(*_args, **_kwargs):
|
|
during["calls"] = [c.args for c in light_client.set_chamber_light.call_args_list]
|
|
yield b"--frame\r\n"
|
|
|
|
with patch("backend.app.services.external_camera.generate_mjpeg_stream", _stream):
|
|
response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/stream")
|
|
|
|
assert response.status_code == 200
|
|
assert during["calls"] == [(True,)]
|
|
assert printer.id in light_svc._off_tasks
|
|
|
|
|
|
async def test_a_bad_stored_value_never_blocks_saving_settings(async_client: AsyncClient, db_session):
|
|
"""The settings page sends every field back on each save. A stored mode or
|
|
delay the update schema refuses would otherwise fail every later save."""
|
|
from backend.app.api.routes.settings import set_setting
|
|
|
|
await set_setting(db_session, "camera_light_mode", "sometimes")
|
|
await set_setting(db_session, "camera_light_delay", "30")
|
|
await db_session.commit()
|
|
|
|
settings = (await async_client.get("/api/v1/settings/")).json()
|
|
assert settings["camera_light_mode"] == "off"
|
|
assert settings["camera_light_delay"] == 5.0
|
|
|
|
resend = {**settings, "auto_archive": not settings["auto_archive"]}
|
|
response = await async_client.put("/api/v1/settings/", json=resend)
|
|
assert response.status_code == 200, response.text
|