test_main.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. """Tests for daemon.main — heartbeat_loop command dispatch and scale wake gating."""
  2. import asyncio
  3. import time
  4. from unittest.mock import AsyncMock, MagicMock, patch
  5. import pytest
  6. from daemon.config import Config
  7. from daemon.main import heartbeat_loop, nfc_poll_loop, scale_poll_loop
  8. def _make_config(**overrides):
  9. cfg = Config(
  10. backend_url="http://localhost:5000",
  11. api_key="test-key",
  12. device_id="dev-1",
  13. hostname="test-host",
  14. heartbeat_interval=0.01, # fast for tests
  15. )
  16. for k, v in overrides.items():
  17. setattr(cfg, k, v)
  18. return cfg
  19. def _make_api():
  20. api = AsyncMock()
  21. api.heartbeat = AsyncMock(return_value=None)
  22. api.update_tare = AsyncMock(return_value={"ok": True})
  23. return api
  24. class TestHeartbeatLoopCommands:
  25. """Test command dispatch in heartbeat_loop."""
  26. @pytest.mark.asyncio
  27. async def test_tare_command_executes_scale_tare(self):
  28. config = _make_config()
  29. api = _make_api()
  30. call_count = 0
  31. async def mock_heartbeat(*args, **kwargs):
  32. nonlocal call_count
  33. call_count += 1
  34. if call_count == 1:
  35. return {"pending_command": "tare"}
  36. return None
  37. api.heartbeat = mock_heartbeat
  38. scale = MagicMock()
  39. scale.ok = True
  40. scale.tare = MagicMock(return_value=512)
  41. display = MagicMock()
  42. display.tick = MagicMock()
  43. shared = {"nfc": None, "scale": scale, "display": display}
  44. task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
  45. await asyncio.sleep(0.1)
  46. task.cancel()
  47. try:
  48. await task
  49. except asyncio.CancelledError:
  50. pass
  51. scale.tare.assert_called_once()
  52. api.update_tare.assert_awaited_once_with("dev-1", 512)
  53. assert config.tare_offset == 512
  54. @pytest.mark.asyncio
  55. async def test_tare_command_no_scale_logs_warning(self):
  56. config = _make_config()
  57. api = _make_api()
  58. call_count = 0
  59. async def mock_heartbeat(*args, **kwargs):
  60. nonlocal call_count
  61. call_count += 1
  62. if call_count == 1:
  63. return {"pending_command": "tare"}
  64. return None
  65. api.heartbeat = mock_heartbeat
  66. display = MagicMock()
  67. display.tick = MagicMock()
  68. shared = {"nfc": None, "scale": None, "display": display}
  69. task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
  70. await asyncio.sleep(0.1)
  71. task.cancel()
  72. try:
  73. await task
  74. except asyncio.CancelledError:
  75. pass
  76. # Should not crash; update_tare should NOT be called
  77. api.update_tare.assert_not_awaited()
  78. @pytest.mark.asyncio
  79. async def test_write_tag_command_sets_pending_write(self):
  80. config = _make_config()
  81. api = _make_api()
  82. call_count = 0
  83. async def mock_heartbeat(*args, **kwargs):
  84. nonlocal call_count
  85. call_count += 1
  86. if call_count == 1:
  87. return {
  88. "pending_command": "write_tag",
  89. "pending_write_payload": {
  90. "spool_id": 42,
  91. "ndef_data_hex": "DEADBEEF",
  92. },
  93. }
  94. return None
  95. api.heartbeat = mock_heartbeat
  96. display = MagicMock()
  97. display.tick = MagicMock()
  98. display.set_brightness = MagicMock()
  99. display.set_blank_timeout = MagicMock()
  100. shared = {"nfc": None, "scale": None, "display": display}
  101. task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
  102. await asyncio.sleep(0.1)
  103. task.cancel()
  104. try:
  105. await task
  106. except asyncio.CancelledError:
  107. pass
  108. assert "pending_write" in shared
  109. assert shared["pending_write"]["spool_id"] == 42
  110. assert shared["pending_write"]["ndef_data"] == bytes.fromhex("DEADBEEF")
  111. # An older Bambuddy sends no v2 encoding: v1 only.
  112. assert shared["pending_write"]["ndef_v2"] is None
  113. @pytest.mark.asyncio
  114. async def test_write_tag_command_keeps_the_v2_encoding(self):
  115. # #3500: Bambuddy sends OpenTag3D v2 beside v1 for a tag with room for it.
  116. config = _make_config()
  117. api = _make_api()
  118. sent = False
  119. async def mock_heartbeat(*args, **kwargs):
  120. nonlocal sent
  121. if sent:
  122. return None
  123. sent = True
  124. return {
  125. "pending_command": "write_tag",
  126. "pending_write_payload": {"spool_id": 42, "ndef_data_hex": "DEADBEEF", "ndef_v2_hex": "CAFE"},
  127. }
  128. api.heartbeat = mock_heartbeat
  129. shared = {"nfc": None, "scale": None, "display": MagicMock()}
  130. task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
  131. await asyncio.sleep(0.1)
  132. task.cancel()
  133. try:
  134. await task
  135. except asyncio.CancelledError:
  136. pass
  137. assert shared["pending_write"]["ndef_v2"] == bytes.fromhex("CAFE")
  138. @pytest.mark.asyncio
  139. async def test_display_settings_applied_from_heartbeat(self):
  140. config = _make_config()
  141. api = _make_api()
  142. call_count = 0
  143. async def mock_heartbeat(*args, **kwargs):
  144. nonlocal call_count
  145. call_count += 1
  146. if call_count == 1:
  147. return {
  148. "display_brightness": 75,
  149. "display_blank_timeout": 300,
  150. }
  151. return None
  152. api.heartbeat = mock_heartbeat
  153. display = MagicMock()
  154. display.tick = MagicMock()
  155. shared = {"nfc": None, "scale": None, "display": display}
  156. task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
  157. await asyncio.sleep(0.1)
  158. task.cancel()
  159. try:
  160. await task
  161. except asyncio.CancelledError:
  162. pass
  163. display.set_brightness.assert_called_with(75)
  164. display.set_blank_timeout.assert_called_with(300)
  165. @pytest.mark.asyncio
  166. async def test_calibration_sync_from_heartbeat(self):
  167. config = _make_config(tare_offset=0, calibration_factor=1.0)
  168. api = _make_api()
  169. call_count = 0
  170. async def mock_heartbeat(*args, **kwargs):
  171. nonlocal call_count
  172. call_count += 1
  173. if call_count == 1:
  174. return {
  175. "tare_offset": 200,
  176. "calibration_factor": 1.05,
  177. }
  178. return None
  179. api.heartbeat = mock_heartbeat
  180. scale = MagicMock()
  181. scale.ok = True
  182. display = MagicMock()
  183. display.tick = MagicMock()
  184. shared = {"nfc": None, "scale": scale, "display": display}
  185. task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
  186. await asyncio.sleep(0.1)
  187. task.cancel()
  188. try:
  189. await task
  190. except asyncio.CancelledError:
  191. pass
  192. assert config.tare_offset == 200
  193. assert config.calibration_factor == 1.05
  194. scale.update_calibration.assert_called_with(200, 1.05)
  195. class TestScalePollLoopWakeGating:
  196. """Regression tests for the wake-from-scale-noise bug.
  197. A noisy load cell that bounces by ≥50g around its midpoint used to fire
  198. display.wake() on every bounce because the threshold check ran against
  199. `last_wake_grams` which itself advanced to noisy values. The fix gates
  200. wake on the scale's `stable` flag so noise can't trigger wake AND
  201. last_wake_grams only advances to settled readings.
  202. """
  203. @staticmethod
  204. def _make_scale(readings):
  205. """Build a scale mock whose .read() yields the given readings then None forever."""
  206. scale = MagicMock()
  207. scale.ok = True
  208. seq = list(readings)
  209. def _read():
  210. if seq:
  211. return seq.pop(0)
  212. return None
  213. scale.read = _read
  214. return scale
  215. @staticmethod
  216. async def _run_loop(scale, display, *, iterations: int):
  217. config = _make_config(scale_read_interval=0.0, scale_report_interval=0.0)
  218. api = AsyncMock()
  219. api.scale_reading = AsyncMock(return_value=None)
  220. shared = {"scale": scale, "display": display}
  221. # Bypass the real threadpool — call scale.read inline so each loop
  222. # iteration consumes exactly one canned reading without races.
  223. async def _inline_to_thread(fn, *args, **kwargs):
  224. return fn(*args, **kwargs)
  225. with patch("daemon.main.asyncio.to_thread", _inline_to_thread):
  226. task = asyncio.create_task(scale_poll_loop(config, api, shared))
  227. for _ in range(iterations):
  228. await asyncio.sleep(0)
  229. task.cancel()
  230. try:
  231. await task
  232. except asyncio.CancelledError:
  233. pass
  234. @pytest.mark.asyncio
  235. async def test_unstable_noise_above_threshold_does_not_wake(self):
  236. """A noisy load cell bouncing ±60g must NOT fire wake repeatedly."""
  237. # All readings are unstable (stable=False) — typical of an unsettled
  238. # load cell. Each crosses the 50g threshold from the previous value.
  239. readings = [
  240. (100.0, False, 1000),
  241. (160.0, False, 1100), # +60g, unstable
  242. (95.0, False, 990), # -65g, unstable
  243. (155.0, False, 1080), # +60g, unstable
  244. (90.0, False, 970), # -65g, unstable
  245. ]
  246. scale = self._make_scale(readings)
  247. display = MagicMock()
  248. await self._run_loop(scale, display, iterations=20)
  249. display.wake.assert_not_called()
  250. @pytest.mark.asyncio
  251. async def test_stable_large_change_wakes(self):
  252. """A real spool placement (settled reading >50g from baseline) wakes."""
  253. # First a settled baseline at 0g, then a settled new reading at 250g.
  254. readings = [
  255. (0.0, True, 100), # baseline stable
  256. (250.0, True, 5000), # spool placed, settled
  257. ]
  258. scale = self._make_scale(readings)
  259. display = MagicMock()
  260. await self._run_loop(scale, display, iterations=20)
  261. # Wake should fire exactly twice: once on first stable reading
  262. # (last_wake_grams was None) and once on the >50g stable change.
  263. assert display.wake.call_count == 2
  264. @pytest.mark.asyncio
  265. async def test_noise_then_settled_wakes_once(self):
  266. """Noise that briefly exceeds threshold must not bump last_wake_grams.
  267. After noise stops and the scale settles at the original baseline,
  268. the next stable reading at a real new value (>50g away) should still
  269. wake — proving last_wake_grams wasn't poisoned by the noise.
  270. """
  271. readings = [
  272. (0.0, True, 100), # initial settled — first wake (None → 0)
  273. (75.0, False, 1500), # noise spike, unstable, ignored
  274. (-50.0, False, -800), # noise dip, unstable, ignored
  275. (80.0, False, 1600), # noise spike, unstable, ignored
  276. (200.0, True, 4000), # spool placed, settled — should wake (>50g from 0)
  277. ]
  278. scale = self._make_scale(readings)
  279. display = MagicMock()
  280. await self._run_loop(scale, display, iterations=30)
  281. # Two stable wake events: initial baseline + real spool placement.
  282. # If last_wake_grams had advanced to 80 during noise, the 200g jump
  283. # would still wake (delta 120 > 50), so this asserts both gating AND
  284. # the absence of poisoning.
  285. assert display.wake.call_count == 2
  286. class TestReRegistration:
  287. """An admin removed the device while the daemon kept running: the next
  288. heartbeat is refused, and the daemon registers again (#sb-1)."""
  289. @pytest.mark.asyncio
  290. async def test_a_refused_heartbeat_registers_the_device_again(self):
  291. config = _make_config()
  292. api = _make_api()
  293. api.unregistered = False
  294. api.register_device = AsyncMock(return_value={"tare_offset": 5, "calibration_factor": 2.0})
  295. async def mock_heartbeat(*args, **kwargs):
  296. api.unregistered = True
  297. return None
  298. api.heartbeat = mock_heartbeat
  299. display = MagicMock()
  300. display.has_backlight = False
  301. shared = {"nfc": None, "scale": None, "display": display}
  302. task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))
  303. await asyncio.sleep(0.05)
  304. task.cancel()
  305. try:
  306. await task
  307. except asyncio.CancelledError:
  308. pass
  309. api.register_device.assert_awaited()
  310. assert api.register_device.await_args.kwargs["device_id"] == "dev-1"
  311. assert config.tare_offset == 5
  312. class TestNfcPollLoopTagScanned:
  313. """nfc_poll_loop forwards everything a tag_detected event carries to the backend (#2685)."""
  314. @pytest.mark.asyncio
  315. async def test_raw_blocks_reach_tag_scanned(self):
  316. raw_blocks = {"1": "41" * 16, "9": "36B6C12DB3DA4A39A50C275F510BD399"}
  317. events = [
  318. (
  319. "tag_detected",
  320. {
  321. "tag_uid": "865947E2",
  322. "sak": 0x08,
  323. "tag_type": "mifare_classic",
  324. "tray_uuid": "36B6C12DB3DA4A39A50C275F510BD399",
  325. "raw_blocks": raw_blocks,
  326. },
  327. )
  328. ]
  329. nfc = MagicMock()
  330. nfc.ok = True
  331. nfc.poll = lambda: events.pop(0) if events else ("none", None)
  332. api = AsyncMock()
  333. config = _make_config(nfc_poll_interval=0.0)
  334. shared = {"nfc": nfc, "display": MagicMock(), "pending_write": None}
  335. async def _inline_to_thread(fn, *args, **kwargs):
  336. return fn(*args, **kwargs)
  337. with patch("daemon.main.asyncio.to_thread", _inline_to_thread):
  338. task = asyncio.create_task(nfc_poll_loop(config, api, shared))
  339. for _ in range(20):
  340. await asyncio.sleep(0)
  341. if api.tag_scanned.await_count:
  342. break
  343. task.cancel()
  344. try:
  345. await task
  346. except asyncio.CancelledError:
  347. pass
  348. api.tag_scanned.assert_awaited_once_with(
  349. device_id="dev-1",
  350. tag_uid="865947E2",
  351. tray_uuid="36B6C12DB3DA4A39A50C275F510BD399",
  352. sak=0x08,
  353. tag_type="mifare_classic",
  354. raw_blocks=raw_blocks,
  355. )
  356. class TestNfcPollLoopWrite:
  357. @pytest.mark.asyncio
  358. async def test_the_v2_encoding_reaches_the_write(self):
  359. # #3500: the reader picks v1 or v2 by the tag's size, so it needs both.
  360. from daemon.nfc_reader import NFCState
  361. nfc = MagicMock()
  362. nfc.ok = True
  363. nfc.poll = lambda: ("none", None)
  364. nfc.state = NFCState.TAG_PRESENT
  365. nfc.current_sak = 0x00
  366. nfc.current_uid = "04A1B2C3"
  367. nfc.write_ntag = MagicMock(return_value=(True, "Write successful (OpenTag3D v2)"))
  368. api = AsyncMock()
  369. config = _make_config(nfc_poll_interval=0.0)
  370. shared = {
  371. "nfc": nfc,
  372. "display": MagicMock(),
  373. "pending_write": {"spool_id": 7, "ndef_data": b"v1", "ndef_v2": b"v2"},
  374. }
  375. async def _inline_to_thread(fn, *args, **kwargs):
  376. return fn(*args, **kwargs)
  377. with patch("daemon.main.asyncio.to_thread", _inline_to_thread):
  378. task = asyncio.create_task(nfc_poll_loop(config, api, shared))
  379. for _ in range(20):
  380. await asyncio.sleep(0)
  381. if api.write_tag_result.await_count:
  382. break
  383. task.cancel()
  384. try:
  385. await task
  386. except asyncio.CancelledError:
  387. pass
  388. nfc.write_ntag.assert_called_once_with(b"v1", b"v2")