test_vp_mqtt_bridge.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769
  1. """Tests for the VP MQTT bridge — non-proxy mirror of target printer state to slicer."""
  2. import asyncio
  3. import json
  4. from pathlib import Path
  5. from unittest.mock import AsyncMock, MagicMock, patch
  6. import pytest
  7. from backend.app.services.virtual_printer.mqtt_bridge import MQTTBridge, _ip_to_uint32_le
  8. from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
  9. H2D_SERIAL = "0948BB540200427"
  10. VP_SERIAL = "09400A391800003"
  11. H2D_IP = "192.168.255.133"
  12. VP_IP = "192.168.255.16"
  13. def _make_server(serial: str = VP_SERIAL, bind_address: str = VP_IP) -> SimpleMQTTServer:
  14. return SimpleMQTTServer(
  15. serial=serial,
  16. access_code="deadbeef",
  17. cert_path=Path("/tmp/unused.crt"), # nosec B108
  18. key_path=Path("/tmp/unused.key"), # nosec B108
  19. model="O1D",
  20. bind_address=bind_address,
  21. )
  22. def _make_paho_client(
  23. serial: str = H2D_SERIAL,
  24. ip: str = H2D_IP,
  25. *,
  26. connected: bool = True,
  27. ) -> MagicMock:
  28. """Build a mock BambuMQTTClient that satisfies MQTTBridge's interface."""
  29. client = MagicMock()
  30. client.serial_number = serial
  31. client.ip_address = ip
  32. client.state = MagicMock()
  33. client.state.connected = connected
  34. client.publish_raw = MagicMock(return_value=True)
  35. client._raw_handlers: list = []
  36. def _register(handler):
  37. client._raw_handlers.append(handler)
  38. def _unregister(handler):
  39. if handler in client._raw_handlers:
  40. client._raw_handlers.remove(handler)
  41. client.register_raw_message_handler.side_effect = _register
  42. client.unregister_raw_message_handler.side_effect = _unregister
  43. # No-op for _request_version / request_status_update so the post-bind nudge doesn't crash.
  44. client._request_version = MagicMock()
  45. client.request_status_update = MagicMock()
  46. return client
  47. def _make_printer_manager(client) -> MagicMock:
  48. pm = MagicMock()
  49. pm.get_client = MagicMock(return_value=client)
  50. return pm
  51. def _make_bridge(server: SimpleMQTTServer, target: MagicMock | None = None) -> MQTTBridge:
  52. target = target if target is not None else _make_paho_client()
  53. pm = _make_printer_manager(target)
  54. return MQTTBridge(
  55. vp_id=1,
  56. vp_name="vp1",
  57. vp_serial=VP_SERIAL,
  58. target_printer_id=42,
  59. mqtt_server=server,
  60. printer_manager=pm,
  61. )
  62. # ---------------------------------------------------------------------------
  63. # Lifecycle
  64. # ---------------------------------------------------------------------------
  65. class TestBridgeLifecycle:
  66. @pytest.mark.asyncio
  67. async def test_start_registers_handler_on_target_client(self):
  68. target = _make_paho_client()
  69. bridge = _make_bridge(_make_server(), target)
  70. await bridge.start()
  71. assert len(target._raw_handlers) == 1
  72. assert bridge.is_active is True
  73. await bridge.stop()
  74. assert len(target._raw_handlers) == 0
  75. @pytest.mark.asyncio
  76. async def test_start_with_no_target_client_does_not_crash(self):
  77. pm = MagicMock()
  78. pm.get_client = MagicMock(return_value=None)
  79. bridge = MQTTBridge(
  80. vp_id=1,
  81. vp_name="vp1",
  82. vp_serial=VP_SERIAL,
  83. target_printer_id=42,
  84. mqtt_server=_make_server(),
  85. printer_manager=pm,
  86. )
  87. await bridge.start()
  88. assert bridge.is_active is False
  89. await bridge.stop()
  90. @pytest.mark.asyncio
  91. async def test_resolve_rebinds_when_paho_client_replaced(self):
  92. """BambuMQTTClient is destroyed and recreated on connect_printer; bridge must rebind."""
  93. old_client = _make_paho_client(serial="REAL_OLD")
  94. new_client = _make_paho_client(serial="REAL_NEW")
  95. pm = _make_printer_manager(old_client)
  96. bridge = MQTTBridge(
  97. vp_id=1,
  98. vp_name="vp1",
  99. vp_serial=VP_SERIAL,
  100. target_printer_id=42,
  101. mqtt_server=_make_server(),
  102. printer_manager=pm,
  103. )
  104. await bridge.start()
  105. assert len(old_client._raw_handlers) == 1
  106. assert bridge._target_serial == "REAL_OLD"
  107. pm.get_client.return_value = new_client
  108. bridge._resolve_client()
  109. assert len(old_client._raw_handlers) == 0
  110. assert len(new_client._raw_handlers) == 1
  111. assert bridge._target_serial == "REAL_NEW"
  112. await bridge.stop()
  113. @pytest.mark.asyncio
  114. async def test_post_bind_nudge_requests_version_and_status(self):
  115. target = _make_paho_client()
  116. bridge = _make_bridge(_make_server(), target)
  117. await bridge.start()
  118. target._request_version.assert_called_once()
  119. target.request_status_update.assert_called_once()
  120. await bridge.stop()
  121. # ---------------------------------------------------------------------------
  122. # Caching: push_status
  123. # ---------------------------------------------------------------------------
  124. class TestPushStatusCache:
  125. """push_status snapshots feed `_send_status_report` via the cache, not a fan-out."""
  126. @pytest.mark.asyncio
  127. async def test_push_status_is_cached_not_fanned_out(self):
  128. server = _make_server()
  129. server.push_raw_to_clients = AsyncMock()
  130. bridge = _make_bridge(server)
  131. await bridge.start()
  132. payload = json.dumps({"print": {"command": "push_status", "ams": {"ams": []}, "gcode_state": "IDLE"}}).encode()
  133. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", payload)
  134. await asyncio.sleep(0.01)
  135. server.push_raw_to_clients.assert_not_awaited()
  136. cached = bridge.get_latest_print_state()
  137. assert cached is not None
  138. assert cached["command"] == "push_status"
  139. assert cached["gcode_state"] == "IDLE"
  140. await bridge.stop()
  141. @pytest.mark.asyncio
  142. async def test_serial_rewritten_in_cached_push(self):
  143. server = _make_server()
  144. bridge = _make_bridge(server)
  145. await bridge.start()
  146. payload = json.dumps(
  147. {
  148. "print": {
  149. "command": "push_status",
  150. "upgrade_state": {"sn": H2D_SERIAL, "status": "IDLE"},
  151. }
  152. }
  153. ).encode()
  154. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", payload)
  155. await asyncio.sleep(0.01)
  156. cached = bridge.get_latest_print_state()
  157. assert cached["upgrade_state"]["sn"] == VP_SERIAL
  158. await bridge.stop()
  159. @pytest.mark.asyncio
  160. async def test_net_info_ip_rewritten_to_vp_ip(self):
  161. """BambuStudio reads `net.info[].ip` (LE uint32) for the FTP destination —
  162. must be rewritten to the VP's bind IP or the slicer bypasses the VP."""
  163. server = _make_server(bind_address=VP_IP)
  164. bridge = _make_bridge(server)
  165. await bridge.start()
  166. h2d_le = _ip_to_uint32_le(H2D_IP)
  167. vp_le = _ip_to_uint32_le(VP_IP)
  168. payload = json.dumps(
  169. {
  170. "print": {
  171. "command": "push_status",
  172. "net": {"info": [{"ip": h2d_le, "mask": 0xFFFFFF}, {"ip": 0, "mask": 0}]},
  173. }
  174. }
  175. ).encode()
  176. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", payload)
  177. await asyncio.sleep(0.01)
  178. cached = bridge.get_latest_print_state()
  179. assert cached["net"]["info"][0]["ip"] == vp_le
  180. assert cached["net"]["info"][1]["ip"] == 0 # untouched
  181. await bridge.stop()
  182. @pytest.mark.asyncio
  183. async def test_request_topic_message_is_ignored(self):
  184. server = _make_server()
  185. bridge = _make_bridge(server)
  186. await bridge.start()
  187. payload = json.dumps({"print": {"command": "push_status"}}).encode()
  188. bridge._on_printer_raw(f"device/{H2D_SERIAL}/request", payload)
  189. await asyncio.sleep(0.01)
  190. assert bridge.get_latest_print_state() is None
  191. await bridge.stop()
  192. @pytest.mark.asyncio
  193. async def test_incremental_push_preserves_ams_from_previous_cache(self):
  194. """Regression for #1371: Bambu firmware sends FULL push_status on
  195. pushall (with AMS/vt_tray/net/etc.) but typically OMITS those fields
  196. from 1 Hz incremental push_status updates. Without preserving the
  197. sticky keys across pushes, the cache forgets AMS info after the first
  198. incremental update, and BambuStudio (which reads the cache via the
  199. VP's 1 Hz status push) sees no AMS info until the user power-cycles
  200. the printer (forcing a fresh pushall).
  201. """
  202. server = _make_server()
  203. bridge = _make_bridge(server)
  204. await bridge.start()
  205. # 1. Initial pushall response with full state, AMS included.
  206. full_push = json.dumps(
  207. {
  208. "print": {
  209. "command": "push_status",
  210. "gcode_state": "IDLE",
  211. "wifi_signal": "-50dBm",
  212. "ams": {
  213. "ams": [
  214. {
  215. "id": "0",
  216. "tray": [
  217. {"id": "0", "tray_type": "PLA", "tray_color": "FF0000FF"},
  218. {"id": "1", "tray_type": "PETG", "tray_color": "00FF00FF"},
  219. ],
  220. }
  221. ],
  222. "tray_exist_bits": "3",
  223. },
  224. "vt_tray": {"id": "254", "tray_type": ""},
  225. "lights_report": [{"node": "chamber_light", "mode": "on"}],
  226. }
  227. }
  228. ).encode()
  229. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", full_push)
  230. await asyncio.sleep(0.01)
  231. cached = bridge.get_latest_print_state()
  232. assert cached["ams"]["ams"][0]["tray"][0]["tray_type"] == "PLA"
  233. assert cached["vt_tray"]["id"] == "254"
  234. assert cached["lights_report"][0]["mode"] == "on"
  235. # 2. Incremental push with only temp/wifi changes — NO ams field.
  236. # This is what the printer sends every ~1 s between full pushalls.
  237. incremental_push = json.dumps(
  238. {
  239. "print": {
  240. "command": "push_status",
  241. "wifi_signal": "-55dBm",
  242. "chamber_temper": 26.0,
  243. }
  244. }
  245. ).encode()
  246. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", incremental_push)
  247. await asyncio.sleep(0.01)
  248. cached = bridge.get_latest_print_state()
  249. # New fields take effect.
  250. assert cached["wifi_signal"] == "-55dBm"
  251. assert cached["chamber_temper"] == 26.0
  252. # Sticky fields preserved from the previous cache (the #1371 fix).
  253. assert "ams" in cached, "AMS field must be preserved across incremental pushes (#1371)"
  254. assert cached["ams"]["ams"][0]["tray"][0]["tray_type"] == "PLA"
  255. assert cached["ams"]["tray_exist_bits"] == "3"
  256. assert cached["vt_tray"]["id"] == "254"
  257. assert cached["lights_report"][0]["mode"] == "on"
  258. await bridge.stop()
  259. @pytest.mark.asyncio
  260. async def test_incoming_ams_update_replaces_cached_ams(self):
  261. """Counterpart to the #1371 fix: preservation only kicks in when the
  262. incoming push OMITS a sticky key. When the printer DOES send a fresh
  263. `ams` value (e.g. on a pushall, or when AMS state genuinely changes),
  264. that value must take effect — the preservation must not shadow real
  265. updates.
  266. """
  267. server = _make_server()
  268. bridge = _make_bridge(server)
  269. await bridge.start()
  270. # 1. Initial state: PLA in tray 0.
  271. bridge._on_printer_raw(
  272. f"device/{H2D_SERIAL}/report",
  273. json.dumps(
  274. {
  275. "print": {
  276. "command": "push_status",
  277. "ams": {"ams": [{"id": "0", "tray": [{"id": "0", "tray_type": "PLA"}]}]},
  278. }
  279. }
  280. ).encode(),
  281. )
  282. await asyncio.sleep(0.01)
  283. # 2. Fresh push with PETG — must replace, not get shadowed by the old PLA.
  284. bridge._on_printer_raw(
  285. f"device/{H2D_SERIAL}/report",
  286. json.dumps(
  287. {
  288. "print": {
  289. "command": "push_status",
  290. "ams": {"ams": [{"id": "0", "tray": [{"id": "0", "tray_type": "PETG"}]}]},
  291. }
  292. }
  293. ).encode(),
  294. )
  295. await asyncio.sleep(0.01)
  296. cached = bridge.get_latest_print_state()
  297. assert cached["ams"]["ams"][0]["tray"][0]["tray_type"] == "PETG"
  298. await bridge.stop()
  299. # ---------------------------------------------------------------------------
  300. # Caching: get_version response
  301. # ---------------------------------------------------------------------------
  302. class TestVersionCache:
  303. @pytest.mark.asyncio
  304. async def test_get_version_response_caches_modules(self):
  305. server = _make_server()
  306. bridge = _make_bridge(server)
  307. await bridge.start()
  308. payload = json.dumps(
  309. {
  310. "info": {
  311. "command": "get_version",
  312. "module": [
  313. {"name": "ota", "sn": H2D_SERIAL, "sw_ver": "01.03.00.00"},
  314. {"name": "n3f/0", "sn": "AMS_HW_1", "sw_ver": "04.00.21.87"},
  315. ],
  316. }
  317. }
  318. ).encode()
  319. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", payload)
  320. await asyncio.sleep(0.01)
  321. modules = bridge.get_latest_version_modules()
  322. assert modules is not None
  323. assert len(modules) == 2
  324. # Device-level sn rewritten; AMS-hardware sn left alone.
  325. assert modules[0]["sn"] == VP_SERIAL
  326. assert modules[1]["sn"] == "AMS_HW_1"
  327. await bridge.stop()
  328. # ---------------------------------------------------------------------------
  329. # Selective fan-out (everything that's not push_status / get_version)
  330. # ---------------------------------------------------------------------------
  331. class TestCommandResponseFanout:
  332. @pytest.mark.asyncio
  333. async def test_extrusion_cali_get_response_is_fanned_out(self):
  334. """Slicer's extrusion_cali_get goes to the printer; the printer's response
  335. must reach the slicer or BambuStudio's pre-flight blocks Send."""
  336. server = _make_server()
  337. server.push_raw_to_clients = AsyncMock()
  338. bridge = _make_bridge(server)
  339. await bridge.start()
  340. body = json.dumps({"print": {"command": "extrusion_cali_get", "filaments": []}}).encode()
  341. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", body)
  342. await asyncio.sleep(0.01)
  343. server.push_raw_to_clients.assert_awaited_once()
  344. topic, _payload = server.push_raw_to_clients.await_args.args
  345. assert topic == f"device/{VP_SERIAL}/report"
  346. await bridge.stop()
  347. # ---------------------------------------------------------------------------
  348. # Forwarding: slicer → printer
  349. # ---------------------------------------------------------------------------
  350. class TestForwardToPrinter:
  351. @pytest.mark.asyncio
  352. async def test_forward_publishes_to_real_serial_request_topic(self):
  353. target = _make_paho_client()
  354. bridge = _make_bridge(_make_server(), target)
  355. await bridge.start()
  356. ok = bridge.forward_to_printer({"print": {"command": "stop"}})
  357. assert ok is True
  358. target.publish_raw.assert_called_once()
  359. topic, payload = target.publish_raw.call_args.args
  360. assert topic == f"device/{H2D_SERIAL}/request"
  361. assert json.loads(payload) == {"print": {"command": "stop"}}
  362. await bridge.stop()
  363. @pytest.mark.asyncio
  364. async def test_forward_returns_false_when_not_bound(self):
  365. pm = MagicMock()
  366. pm.get_client = MagicMock(return_value=None)
  367. bridge = MQTTBridge(
  368. vp_id=1,
  369. vp_name="vp1",
  370. vp_serial=VP_SERIAL,
  371. target_printer_id=42,
  372. mqtt_server=_make_server(),
  373. printer_manager=pm,
  374. )
  375. await bridge.start()
  376. assert bridge.forward_to_printer({"print": {"command": "stop"}}) is False
  377. await bridge.stop()
  378. # ---------------------------------------------------------------------------
  379. # SimpleMQTTServer status response: cached-as-base
  380. # ---------------------------------------------------------------------------
  381. class TestStatusReportCachedAsBase:
  382. """`_send_status_report` sends near-byte-identical real data when bridge cache exists."""
  383. def _capture_published(self, server: SimpleMQTTServer):
  384. """Wrap _publish_to_report to capture (topic, payload_dict)."""
  385. published: list = []
  386. async def _capture(writer, payload, serial=""):
  387. published.append((serial or server.serial, payload))
  388. server._publish_to_report = _capture # type: ignore[assignment]
  389. return published
  390. @pytest.mark.asyncio
  391. async def test_uses_real_cache_when_bridge_active(self):
  392. server = _make_server()
  393. bridge = MagicMock()
  394. bridge.get_latest_print_state.return_value = {
  395. "command": "push_status",
  396. "msg": 0,
  397. "ams": {"ams": [{"id": "0"}]},
  398. "device": {"extruder": {"info": [{"id": 0}, {"id": 1}]}},
  399. "nozzle_diameter": "0.4",
  400. "nozzle_type": "HH01", # real H2D value, not synthetic 'hardened_steel'
  401. }
  402. server.set_bridge(bridge)
  403. published = self._capture_published(server)
  404. await server._send_status_report(MagicMock())
  405. assert len(published) == 1
  406. _serial, payload = published[0]
  407. # AMS / device / nozzle_type all from cache
  408. assert payload["print"]["nozzle_type"] == "HH01"
  409. assert payload["print"]["device"]["extruder"]["info"][1]["id"] == 1
  410. # Protocol fields under our control
  411. assert payload["print"]["command"] == "push_status"
  412. assert payload["print"]["gcode_state"] == "IDLE"
  413. @pytest.mark.asyncio
  414. async def test_falls_back_to_synthetic_when_no_cache(self):
  415. server = _make_server()
  416. bridge = MagicMock()
  417. bridge.get_latest_print_state.return_value = None
  418. server.set_bridge(bridge)
  419. published = self._capture_published(server)
  420. await server._send_status_report(MagicMock())
  421. assert len(published) == 1
  422. _serial, payload = published[0]
  423. # Synthetic baseline has stub fields like nozzle_type='hardened_steel'
  424. # and a `storage` field that the real H2D doesn't push.
  425. assert payload["print"]["nozzle_type"] == "hardened_steel"
  426. assert "storage" in payload["print"]
  427. @pytest.mark.asyncio
  428. async def test_storage_indicators_overlaid_for_send_preflight(self):
  429. """#1228: P1S/A1-class firmware doesn't always include the SD/storage
  430. fields BambuStudio's "Send" pre-flight reads. Without these the
  431. slicer rejects with 'storage needs to be inserted' before even
  432. attempting FTP. The cached-as-base path now overlays them so the
  433. pre-flight passes regardless of what the real printer reports.
  434. """
  435. server = _make_server()
  436. bridge = MagicMock()
  437. # Real P1S push without SD card inserted: home_flag has other bits set
  438. # but the SD bit (0x100) is clear; sdcard is False; no storage field.
  439. bridge.get_latest_print_state.return_value = {
  440. "command": "push_status",
  441. "msg": 0,
  442. "home_flag": 0x42,
  443. "sdcard": False,
  444. }
  445. server.set_bridge(bridge)
  446. published = self._capture_published(server)
  447. await server._send_status_report(MagicMock())
  448. _serial, payload = published[0]
  449. # SD bit ORed onto whatever was there — other bits preserved.
  450. assert payload["print"]["home_flag"] & 0x100 == 0x100
  451. assert payload["print"]["home_flag"] & 0x42 == 0x42
  452. # Force-set so a False from the printer doesn't trip the pre-flight.
  453. assert payload["print"]["sdcard"] is True
  454. # storage was missing — the overlay must inject a non-empty default.
  455. assert "storage" in payload["print"]
  456. assert payload["print"]["storage"]["free"] > 0
  457. assert payload["print"]["storage"]["total"] > 0
  458. @pytest.mark.asyncio
  459. async def test_storage_indicators_preserve_real_storage_when_present(self):
  460. """When the real printer DOES report a storage block, pass it through
  461. unchanged (the overlay only fills in the missing field, not overrides).
  462. """
  463. server = _make_server()
  464. bridge = MagicMock()
  465. real_storage = {"free": 12345, "total": 67890}
  466. bridge.get_latest_print_state.return_value = {
  467. "command": "push_status",
  468. "msg": 0,
  469. "home_flag": 0x100, # SD bit already set on the real printer
  470. "sdcard": True,
  471. "storage": real_storage,
  472. }
  473. server.set_bridge(bridge)
  474. published = self._capture_published(server)
  475. await server._send_status_report(MagicMock())
  476. _serial, payload = published[0]
  477. # SD bit OR is idempotent — already-set bit stays set.
  478. assert payload["print"]["home_flag"] == 0x100
  479. assert payload["print"]["sdcard"] is True
  480. # Real values pass through, NOT the synthetic defaults.
  481. assert payload["print"]["storage"] == real_storage
  482. @pytest.mark.asyncio
  483. async def test_overrides_protocol_fields_even_when_cache_present(self):
  484. """Cached value's gcode_state must NOT win over our local upload-state-machine value."""
  485. server = _make_server()
  486. server._gcode_state = "PREPARE"
  487. server._current_file = "foo.3mf"
  488. bridge = MagicMock()
  489. bridge.get_latest_print_state.return_value = {
  490. "command": "push_status",
  491. "gcode_state": "IDLE", # printer is idle; we are mid-FTP-upload
  492. "gcode_file": "",
  493. "gcode_file_prepare_percent": "0",
  494. }
  495. server.set_bridge(bridge)
  496. published = self._capture_published(server)
  497. await server._send_status_report(MagicMock())
  498. _serial, payload = published[0]
  499. assert payload["print"]["gcode_state"] == "PREPARE"
  500. assert payload["print"]["gcode_file"] == "foo.3mf"
  501. # ---------------------------------------------------------------------------
  502. # Wire format
  503. # ---------------------------------------------------------------------------
  504. class TestWireFormat:
  505. """BambuStudio's Send pre-flight rejects compact JSON — must match real printer's
  506. indented format (32K bytes for an idle H2D vs 14K compact)."""
  507. @pytest.mark.asyncio
  508. async def test_publish_uses_indent_4_json_format(self):
  509. server = _make_server()
  510. captured: list = []
  511. async def _capture_drain():
  512. pass
  513. writer = MagicMock()
  514. writer.write = lambda data: captured.append(data)
  515. writer.drain = AsyncMock()
  516. await server._publish_to_report(writer, {"print": {"command": "push_status", "ams": {}}})
  517. body = b"".join(captured)
  518. assert b'\n "print"' in body, "publish_to_report must use indent=4 JSON"
  519. # ---------------------------------------------------------------------------
  520. # Routing: _handle_publish
  521. # ---------------------------------------------------------------------------
  522. class TestPublishRouting:
  523. """Slicer-issued commands: project_file/gcode_file handled locally, everything
  524. else forwarded to the real printer."""
  525. def _build_publish_payload(self, topic: str, body: bytes) -> bytes:
  526. topic_bytes = topic.encode("utf-8")
  527. return bytes([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF]) + topic_bytes + body
  528. def _attach_active_bridge(self, server: SimpleMQTTServer) -> MagicMock:
  529. bridge = MagicMock()
  530. bridge.is_active = True
  531. bridge.forward_to_printer = MagicMock(return_value=True)
  532. server.set_bridge(bridge)
  533. return bridge
  534. @pytest.mark.asyncio
  535. async def test_project_file_handled_locally_not_forwarded(self):
  536. server = _make_server()
  537. bridge = self._attach_active_bridge(server)
  538. writer = MagicMock()
  539. writer.write = MagicMock()
  540. writer.drain = AsyncMock()
  541. body = json.dumps({"print": {"command": "project_file", "subtask_name": "f", "sequence_id": "1"}}).encode()
  542. payload = self._build_publish_payload(f"device/{VP_SERIAL}/request", body)
  543. with patch.object(server, "_send_print_response", new=AsyncMock()) as mock_resp:
  544. await server._handle_publish(0x30, payload, writer, "client1")
  545. bridge.forward_to_printer.assert_not_called()
  546. mock_resp.assert_awaited_once()
  547. @pytest.mark.asyncio
  548. async def test_gcode_file_handled_locally_not_forwarded(self):
  549. server = _make_server()
  550. bridge = self._attach_active_bridge(server)
  551. writer = MagicMock()
  552. writer.write = MagicMock()
  553. writer.drain = AsyncMock()
  554. body = json.dumps({"print": {"command": "gcode_file", "subtask_name": "f.gcode", "sequence_id": "1"}}).encode()
  555. payload = self._build_publish_payload(f"device/{VP_SERIAL}/request", body)
  556. with patch.object(server, "_send_print_response", new=AsyncMock()):
  557. await server._handle_publish(0x30, payload, writer, "client1")
  558. bridge.forward_to_printer.assert_not_called()
  559. @pytest.mark.asyncio
  560. async def test_pushall_handled_locally_not_forwarded(self):
  561. server = _make_server()
  562. bridge = self._attach_active_bridge(server)
  563. writer = MagicMock()
  564. writer.write = MagicMock()
  565. writer.drain = AsyncMock()
  566. body = json.dumps({"pushing": {"command": "pushall", "sequence_id": "0"}}).encode()
  567. payload = self._build_publish_payload(f"device/{VP_SERIAL}/request", body)
  568. with patch.object(server, "_send_status_report", new=AsyncMock()) as mock_status:
  569. await server._handle_publish(0x30, payload, writer, "client1")
  570. # Synthetic answer fires (fast, low latency); no forwarding (the
  571. # cache already mirrors what the printer would respond with).
  572. bridge.forward_to_printer.assert_not_called()
  573. mock_status.assert_awaited_once()
  574. @pytest.mark.asyncio
  575. async def test_get_version_handled_locally_not_forwarded(self):
  576. server = _make_server()
  577. bridge = self._attach_active_bridge(server)
  578. writer = MagicMock()
  579. writer.write = MagicMock()
  580. writer.drain = AsyncMock()
  581. body = json.dumps({"info": {"command": "get_version", "sequence_id": "1"}}).encode()
  582. payload = self._build_publish_payload(f"device/{VP_SERIAL}/request", body)
  583. with patch.object(server, "_send_version_response", new=AsyncMock()) as mock_ver:
  584. await server._handle_publish(0x30, payload, writer, "client1")
  585. bridge.forward_to_printer.assert_not_called()
  586. mock_ver.assert_awaited_once()
  587. @pytest.mark.asyncio
  588. async def test_extrusion_cali_get_is_forwarded(self):
  589. """extrusion_cali_get fetches per-filament k-profiles — must reach the printer."""
  590. server = _make_server()
  591. bridge = self._attach_active_bridge(server)
  592. writer = MagicMock()
  593. writer.write = MagicMock()
  594. writer.drain = AsyncMock()
  595. body = json.dumps(
  596. {
  597. "print": {
  598. "command": "extrusion_cali_get",
  599. "filament_id": "",
  600. "nozzle_diameter": "0.4",
  601. "sequence_id": "5",
  602. }
  603. }
  604. ).encode()
  605. payload = self._build_publish_payload(f"device/{VP_SERIAL}/request", body)
  606. await server._handle_publish(0x30, payload, writer, "client1")
  607. bridge.forward_to_printer.assert_called_once()
  608. forwarded = bridge.forward_to_printer.call_args.args[0]
  609. assert forwarded["print"]["command"] == "extrusion_cali_get"
  610. @pytest.mark.asyncio
  611. async def test_print_stop_is_forwarded(self):
  612. server = _make_server()
  613. bridge = self._attach_active_bridge(server)
  614. writer = MagicMock()
  615. writer.write = MagicMock()
  616. writer.drain = AsyncMock()
  617. body = json.dumps({"print": {"command": "stop", "sequence_id": "5"}}).encode()
  618. payload = self._build_publish_payload(f"device/{VP_SERIAL}/request", body)
  619. await server._handle_publish(0x30, payload, writer, "client1")
  620. bridge.forward_to_printer.assert_called_once()
  621. # ---------------------------------------------------------------------------
  622. # IP encoding helper
  623. # ---------------------------------------------------------------------------
  624. class TestIpEncoding:
  625. def test_le_uint32_matches_real_h2d_capture(self):
  626. # 192.168.255.133 captured from real H2D's net.info[0].ip = 2248124608
  627. assert _ip_to_uint32_le("192.168.255.133") == 2248124608
  628. def test_vp_ip_round_trip(self):
  629. assert _ip_to_uint32_le("192.168.255.16") == 285190336
  630. def test_invalid_ip_raises(self):
  631. with pytest.raises(ValueError):
  632. _ip_to_uint32_le("not.an.ip.actually")