| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225 |
- """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
|