test_main.py 20 KB

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