test_plate_clear_mqtt_notification.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. """Tests for the plate-clear gate reaching MQTT and notifications (#2525).
  2. ``awaiting_plate_clear`` is a Bambuddy-side flag (#961) — the printer's own MQTT
  3. push only ever reports RUNNING/PAUSE/FAILED/FINISH/IDLE, so an external
  4. automation had no way to tell "finished" from "finished and still waiting for
  5. someone to clear the bed". It now rides along on the retained per-printer status
  6. topic, gets its own retained topic on every transition, and can raise a
  7. notification on the rising edge.
  8. """
  9. from types import SimpleNamespace
  10. from unittest.mock import AsyncMock, MagicMock, patch
  11. import pytest
  12. from backend.app.services.mqtt_relay import MQTTRelayService
  13. from backend.app.services.printer_manager import PrinterManager
  14. def _relay() -> MQTTRelayService:
  15. relay = MQTTRelayService()
  16. relay.enabled = True
  17. relay.connected = True
  18. relay.client = MagicMock()
  19. return relay
  20. def _state() -> SimpleNamespace:
  21. return SimpleNamespace(
  22. connected=True,
  23. state="FINISH",
  24. progress=100,
  25. remaining_time=0,
  26. layer_num=250,
  27. total_layers=250,
  28. current_print="benchy.gcode.3mf",
  29. subtask_name="benchy",
  30. gcode_file="benchy.gcode",
  31. temperatures={"nozzle": 40, "bed": 30},
  32. wifi_signal="-50dBm",
  33. chamber_light="off",
  34. speed_level=2,
  35. cooling_fan_speed=0,
  36. big_fan1_speed=0,
  37. big_fan2_speed=0,
  38. heatbreak_fan_speed=0,
  39. left_aux_fan_speed=None,
  40. exhaust_fan_present=False,
  41. )
  42. def _published(relay: MQTTRelayService) -> list[tuple[str, dict, bool]]:
  43. """Decode every publish as (topic, payload, retain)."""
  44. import json
  45. calls = []
  46. for call in relay.client.publish.call_args_list:
  47. topic = call.args[0]
  48. payload = json.loads(call.args[1])
  49. calls.append((topic, payload, call.kwargs.get("retain", False)))
  50. return calls
  51. class TestStatusPayload:
  52. @pytest.mark.asyncio
  53. async def test_status_payload_carries_awaiting_plate_clear(self):
  54. relay = _relay()
  55. await relay.on_printer_status(1, _state(), "X1C", "01P00A000000001", True)
  56. topic, payload, retain = _published(relay)[0]
  57. assert topic == "bambuddy/printers/01P00A000000001/status"
  58. assert payload["awaiting_plate_clear"] is True
  59. assert retain is True
  60. @pytest.mark.asyncio
  61. async def test_status_payload_defaults_to_not_awaiting(self):
  62. relay = _relay()
  63. await relay.on_printer_status(1, _state(), "X1C", "01P00A000000001")
  64. _, payload, _ = _published(relay)[0]
  65. assert payload["awaiting_plate_clear"] is False
  66. # The pre-existing telemetry fields must survive the addition.
  67. assert payload["state"] == "FINISH"
  68. assert payload["progress"] == 100
  69. class TestPlateClearTopic:
  70. @pytest.mark.asyncio
  71. async def test_publishes_retained_state_on_its_own_topic(self):
  72. relay = _relay()
  73. await relay.on_plate_clear_state(3, "P1S", "01S00C000000003", True)
  74. topic, payload, retain = _published(relay)[0]
  75. assert topic == "bambuddy/printers/01S00C000000003/plate_clear"
  76. assert payload["awaiting"] is True
  77. assert payload["printer_id"] == 3
  78. assert payload["printer_name"] == "P1S"
  79. assert payload["printer_serial"] == "01S00C000000003"
  80. # Retained so a subscriber that connects later learns the current state
  81. # instead of waiting for the next transition.
  82. assert retain is True
  83. @pytest.mark.asyncio
  84. async def test_honours_the_configured_topic_prefix(self):
  85. relay = _relay()
  86. relay.topic_prefix = "farm/bambuddy"
  87. await relay.on_plate_clear_state(3, "P1S", "01S00C000000003", False)
  88. topic, payload, _ = _published(relay)[0]
  89. assert topic == "farm/bambuddy/printers/01S00C000000003/plate_clear"
  90. assert payload["awaiting"] is False
  91. @pytest.mark.asyncio
  92. async def test_silent_when_relay_is_disabled(self):
  93. relay = _relay()
  94. relay.enabled = False
  95. await relay.on_plate_clear_state(3, "P1S", "01S00C000000003", True)
  96. relay.client.publish.assert_not_called()
  97. class TestEdgeTriggering:
  98. """The setter is re-asserted routinely (the queue clears the gate on every
  99. dispatch), so outward-facing emissions must fire on transitions only."""
  100. def _manager(self) -> PrinterManager:
  101. manager = PrinterManager()
  102. loop = MagicMock()
  103. loop.is_running.return_value = True
  104. manager._loop = loop
  105. return manager
  106. def test_emits_on_the_rising_edge(self):
  107. manager = self._manager()
  108. with patch.object(manager, "_schedule_async") as scheduled:
  109. manager.set_awaiting_plate_clear(7, True)
  110. emitted = [c for c in scheduled.call_args_list if "_emit_plate_clear_change" in repr(c.args[0])]
  111. assert len(emitted) == 1
  112. for call in scheduled.call_args_list:
  113. call.args[0].close()
  114. def test_does_not_re_emit_when_already_awaiting(self):
  115. manager = self._manager()
  116. manager._awaiting_plate_clear.add(7)
  117. with patch.object(manager, "_schedule_async") as scheduled:
  118. manager.set_awaiting_plate_clear(7, True)
  119. emitted = [c for c in scheduled.call_args_list if "_emit_plate_clear_change" in repr(c.args[0])]
  120. assert emitted == []
  121. # Persistence and the WebSocket broadcast are idempotent and stay unconditional.
  122. assert len(scheduled.call_args_list) == 2
  123. for call in scheduled.call_args_list:
  124. call.args[0].close()
  125. def test_does_not_emit_when_clearing_a_gate_that_was_never_up(self):
  126. manager = self._manager()
  127. with patch.object(manager, "_schedule_async") as scheduled:
  128. manager.set_awaiting_plate_clear(7, False)
  129. emitted = [c for c in scheduled.call_args_list if "_emit_plate_clear_change" in repr(c.args[0])]
  130. assert emitted == []
  131. for call in scheduled.call_args_list:
  132. call.args[0].close()
  133. def test_emits_on_the_falling_edge(self):
  134. manager = self._manager()
  135. manager._awaiting_plate_clear.add(7)
  136. with patch.object(manager, "_schedule_async") as scheduled:
  137. manager.set_awaiting_plate_clear(7, False)
  138. emitted = [c for c in scheduled.call_args_list if "_emit_plate_clear_change" in repr(c.args[0])]
  139. assert len(emitted) == 1
  140. for call in scheduled.call_args_list:
  141. call.args[0].close()
  142. class TestEmitFanOut:
  143. @pytest.mark.asyncio
  144. async def test_rising_edge_publishes_and_notifies(self):
  145. manager = PrinterManager()
  146. manager._printer_info[7] = SimpleNamespace(name="X1C", serial_number="01P00A000000001")
  147. publish = AsyncMock()
  148. notify = AsyncMock()
  149. with (
  150. patch("backend.app.services.mqtt_relay.mqtt_relay.on_plate_clear_state", publish),
  151. patch(
  152. "backend.app.services.notification_service.notification_service.on_plate_clear_required",
  153. notify,
  154. ),
  155. ):
  156. await manager._emit_plate_clear_change(7, True)
  157. publish.assert_awaited_once_with(7, "X1C", "01P00A000000001", True)
  158. assert notify.await_count == 1
  159. assert notify.await_args.args[:2] == (7, "X1C")
  160. @pytest.mark.asyncio
  161. async def test_falling_edge_publishes_but_does_not_notify(self):
  162. manager = PrinterManager()
  163. manager._printer_info[7] = SimpleNamespace(name="X1C", serial_number="01P00A000000001")
  164. publish = AsyncMock()
  165. notify = AsyncMock()
  166. with (
  167. patch("backend.app.services.mqtt_relay.mqtt_relay.on_plate_clear_state", publish),
  168. patch(
  169. "backend.app.services.notification_service.notification_service.on_plate_clear_required",
  170. notify,
  171. ),
  172. ):
  173. await manager._emit_plate_clear_change(7, False)
  174. publish.assert_awaited_once_with(7, "X1C", "01P00A000000001", False)
  175. notify.assert_not_awaited()
  176. @pytest.mark.asyncio
  177. async def test_unknown_printer_is_a_no_op(self):
  178. manager = PrinterManager()
  179. publish = AsyncMock()
  180. with patch("backend.app.services.mqtt_relay.mqtt_relay.on_plate_clear_state", publish):
  181. await manager._emit_plate_clear_change(999, True)
  182. publish.assert_not_awaited()
  183. @pytest.mark.asyncio
  184. async def test_falls_back_to_the_db_when_no_client_is_registered(self):
  185. """A printer disconnected outright has no cached info, but the gate can
  186. still be released through the API (#2864) — the retained topic must not
  187. be left asserting a state that is no longer true."""
  188. manager = PrinterManager()
  189. publish = AsyncMock()
  190. with (
  191. patch("backend.app.services.mqtt_relay.mqtt_relay.on_plate_clear_state", publish),
  192. patch.object(
  193. PrinterManager,
  194. "_printer_info_from_db",
  195. AsyncMock(return_value=SimpleNamespace(name="Powered-off X1C", serial_number="01P00A000000009")),
  196. ),
  197. ):
  198. await manager._emit_plate_clear_change(9, False)
  199. publish.assert_awaited_once_with(9, "Powered-off X1C", "01P00A000000009", False)
  200. @pytest.mark.asyncio
  201. async def test_mqtt_failure_does_not_block_the_notification(self):
  202. manager = PrinterManager()
  203. manager._printer_info[7] = SimpleNamespace(name="X1C", serial_number="01P00A000000001")
  204. notify = AsyncMock()
  205. with (
  206. patch(
  207. "backend.app.services.mqtt_relay.mqtt_relay.on_plate_clear_state",
  208. AsyncMock(side_effect=RuntimeError("broker down")),
  209. ),
  210. patch(
  211. "backend.app.services.notification_service.notification_service.on_plate_clear_required",
  212. notify,
  213. ),
  214. ):
  215. await manager._emit_plate_clear_change(7, True)
  216. assert notify.await_count == 1