test_camera_light_api.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. """Chamber light for the camera (#1655): the settings, and the routes that hold the light.
  2. The light mode and delay are set through the settings API here, so the service
  3. reads them back from the database exactly as it does in production."""
  4. from types import SimpleNamespace
  5. from unittest.mock import MagicMock, patch
  6. import pytest
  7. from httpx import AsyncClient
  8. from backend.app.services import camera_light as light_svc
  9. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  10. @pytest.fixture
  11. def light_client(monkeypatch):
  12. client = MagicMock()
  13. client.state = SimpleNamespace(chamber_light=False)
  14. client.set_chamber_light.return_value = True
  15. monkeypatch.setattr(light_svc, "_get_client", lambda _pid: client)
  16. monkeypatch.setattr(light_svc, "_holders", {})
  17. monkeypatch.setattr(light_svc, "_lit_at", {})
  18. monkeypatch.setattr(light_svc, "_settings_cache", None)
  19. off_tasks: dict = {}
  20. monkeypatch.setattr(light_svc, "_off_tasks", off_tasks)
  21. yield client
  22. for task in off_tasks.values():
  23. task.cancel()
  24. async def _set_light(async_client: AsyncClient, **values):
  25. response = await async_client.patch("/api/v1/settings/", json=values)
  26. assert response.status_code == 200, response.text
  27. return response.json()
  28. async def test_the_light_is_off_by_default(async_client: AsyncClient, printer_factory):
  29. printer = await printer_factory()
  30. settings = (await async_client.get("/api/v1/settings/")).json()
  31. assert settings["camera_light_mode"] == "off"
  32. assert settings["camera_light_delay"] == 2.0
  33. assert (await async_client.get(f"/api/v1/printers/{printer.id}")).json()["camera_light_auto"] is False
  34. async def test_the_mode_and_delay_are_saved(async_client: AsyncClient):
  35. saved = await _set_light(async_client, camera_light_mode="selected", camera_light_delay=1.5)
  36. assert saved["camera_light_mode"] == "selected"
  37. assert saved["camera_light_delay"] == 1.5
  38. async def test_a_printer_is_picked_for_the_selected_mode(async_client: AsyncClient, printer_factory):
  39. printer = await printer_factory()
  40. response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"camera_light_auto": True})
  41. assert response.status_code == 200
  42. assert response.json()["camera_light_auto"] is True
  43. @pytest.mark.parametrize(
  44. "values",
  45. [
  46. {"camera_light_mode": "sometimes"},
  47. {"camera_light_delay": -1},
  48. {"camera_light_delay": 5.5},
  49. {"camera_light_delay": None},
  50. ],
  51. )
  52. async def test_bad_light_settings_are_refused(async_client: AsyncClient, values):
  53. response = await async_client.patch("/api/v1/settings/", json=values)
  54. assert response.status_code == 422
  55. async def test_a_snapshot_is_taken_with_the_light_on(async_client: AsyncClient, printer_factory, light_client):
  56. """Home Assistant and other automations take their pictures here."""
  57. await _set_light(async_client, camera_light_mode="all", camera_light_delay=0)
  58. printer = await printer_factory()
  59. fake_jpeg = b"\xff\xd8\xff\xe0snap"
  60. seen = {}
  61. async def _snapshot(printer_id, _printer):
  62. seen["calls"] = list(light_client.set_chamber_light.call_args_list)
  63. from fastapi import Response
  64. return Response(content=fake_jpeg, media_type="image/jpeg")
  65. with patch("backend.app.api.routes.camera._snapshot_response", _snapshot):
  66. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
  67. assert response.status_code == 200
  68. assert response.content == fake_jpeg
  69. assert [c.args for c in seen["calls"]] == [(True,)]
  70. async def test_a_snapshot_leaves_the_light_alone_by_default(async_client: AsyncClient, printer_factory, light_client):
  71. printer = await printer_factory()
  72. async def _snapshot(printer_id, _printer):
  73. from fastapi import Response
  74. return Response(content=b"\xff\xd8", media_type="image/jpeg")
  75. with patch("backend.app.api.routes.camera._snapshot_response", _snapshot):
  76. await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
  77. light_client.set_chamber_light.assert_not_called()
  78. async def test_selected_mode_lights_only_the_picked_printers(async_client: AsyncClient, printer_factory, light_client):
  79. await _set_light(async_client, camera_light_mode="selected", camera_light_delay=0)
  80. picked = await printer_factory(camera_light_auto=True)
  81. other = await printer_factory(serial_number="00M09A999999999")
  82. async def _snapshot(printer_id, _printer):
  83. from fastapi import Response
  84. return Response(content=b"\xff\xd8", media_type="image/jpeg")
  85. with patch("backend.app.api.routes.camera._snapshot_response", _snapshot):
  86. await async_client.get(f"/api/v1/printers/{other.id}/camera/snapshot")
  87. light_client.set_chamber_light.assert_not_called()
  88. await async_client.get(f"/api/v1/printers/{picked.id}/camera/snapshot")
  89. light_client.set_chamber_light.assert_called_once_with(True)
  90. async def test_saving_the_mode_applies_at_once(async_client: AsyncClient, printer_factory, light_client):
  91. """The service caches the settings; saving them must not wait out the cache."""
  92. printer = await printer_factory()
  93. async def _snapshot(printer_id, _printer):
  94. from fastapi import Response
  95. return Response(content=b"\xff\xd8", media_type="image/jpeg")
  96. with patch("backend.app.api.routes.camera._snapshot_response", _snapshot):
  97. await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot") # caches "off"
  98. await _set_light(async_client, camera_light_mode="all", camera_light_delay=0)
  99. await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
  100. light_client.set_chamber_light.assert_called_once_with(True)
  101. async def test_switching_the_light_by_hand_hands_it_back(async_client: AsyncClient, printer_factory):
  102. printer = await printer_factory()
  103. light_svc._lit_at[printer.id] = 0.0
  104. client = MagicMock()
  105. client.set_chamber_light.return_value = True
  106. try:
  107. with patch("backend.app.api.routes.printers.printer_manager") as pm:
  108. pm.get_client.return_value = client
  109. response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
  110. assert response.status_code == 200
  111. assert printer.id not in light_svc._lit_at
  112. finally:
  113. light_svc._lit_at.pop(printer.id, None)
  114. async def test_the_live_view_holds_the_light_while_it_streams(async_client: AsyncClient, printer_factory, light_client):
  115. await _set_light(async_client, camera_light_mode="all", camera_light_delay=5)
  116. printer = await printer_factory()
  117. during = {}
  118. async def _iter_subscriber(*_args, **_kwargs):
  119. during["calls"] = [c.args for c in light_client.set_chamber_light.call_args_list]
  120. yield b"--frame\r\n"
  121. broadcaster = MagicMock(subscriber_count=1)
  122. async def _subscribe():
  123. return MagicMock()
  124. broadcaster.subscribe = _subscribe
  125. async def _get_broadcaster(_key, _factory):
  126. return broadcaster
  127. with (
  128. patch("backend.app.api.routes.camera.get_or_create_broadcaster", _get_broadcaster),
  129. patch("backend.app.api.routes.camera.iter_subscriber", _iter_subscriber),
  130. ):
  131. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/stream")
  132. assert response.status_code == 200
  133. assert during["calls"] == [(True,)]
  134. # The viewer left: the light goes off after the grace, not at once.
  135. assert printer.id in light_svc._off_tasks
  136. async def test_an_external_camera_view_holds_the_light_too(async_client: AsyncClient, printer_factory, light_client):
  137. await _set_light(async_client, camera_light_mode="selected")
  138. printer = await printer_factory(
  139. camera_light_auto=True,
  140. external_camera_enabled=True,
  141. external_camera_url="http://192.0.2.50/mjpeg",
  142. external_camera_type="mjpeg",
  143. )
  144. during = {}
  145. async def _stream(*_args, **_kwargs):
  146. during["calls"] = [c.args for c in light_client.set_chamber_light.call_args_list]
  147. yield b"--frame\r\n"
  148. with patch("backend.app.services.external_camera.generate_mjpeg_stream", _stream):
  149. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/stream")
  150. assert response.status_code == 200
  151. assert during["calls"] == [(True,)]
  152. assert printer.id in light_svc._off_tasks
  153. async def test_a_bad_stored_value_never_blocks_saving_settings(async_client: AsyncClient, db_session):
  154. """The settings page sends every field back on each save. A stored mode or
  155. delay the update schema refuses would otherwise fail every later save."""
  156. from backend.app.api.routes.settings import set_setting
  157. await set_setting(db_session, "camera_light_mode", "sometimes")
  158. await set_setting(db_session, "camera_light_delay", "30")
  159. await db_session.commit()
  160. settings = (await async_client.get("/api/v1/settings/")).json()
  161. assert settings["camera_light_mode"] == "off"
  162. assert settings["camera_light_delay"] == 5.0
  163. resend = {**settings, "auto_archive": not settings["auto_archive"]}
  164. response = await async_client.put("/api/v1/settings/", json=resend)
  165. assert response.status_code == 200, response.text