test_vp_mqtt_bridge.py 46 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186
  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 (
  8. MQTTBridge,
  9. _ip_to_uint32_le,
  10. _resolve_host_interface_for_target,
  11. )
  12. from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
  13. H2D_SERIAL = "0948BB540200427"
  14. VP_SERIAL = "09400A391800003"
  15. H2D_IP = "192.168.255.133"
  16. VP_IP = "192.168.255.16"
  17. def _make_server(serial: str = VP_SERIAL, bind_address: str = VP_IP) -> SimpleMQTTServer:
  18. return SimpleMQTTServer(
  19. serial=serial,
  20. access_code="deadbeef",
  21. cert_path=Path("/tmp/unused.crt"), # nosec B108
  22. key_path=Path("/tmp/unused.key"), # nosec B108
  23. model="O1D",
  24. bind_address=bind_address,
  25. )
  26. def _make_paho_client(
  27. serial: str = H2D_SERIAL,
  28. ip: str = H2D_IP,
  29. *,
  30. connected: bool = True,
  31. ) -> MagicMock:
  32. """Build a mock BambuMQTTClient that satisfies MQTTBridge's interface."""
  33. client = MagicMock()
  34. client.serial_number = serial
  35. client.ip_address = ip
  36. client.state = MagicMock()
  37. client.state.connected = connected
  38. client.publish_raw = MagicMock(return_value=True)
  39. client._raw_handlers: list = []
  40. def _register(handler):
  41. client._raw_handlers.append(handler)
  42. def _unregister(handler):
  43. if handler in client._raw_handlers:
  44. client._raw_handlers.remove(handler)
  45. client.register_raw_message_handler.side_effect = _register
  46. client.unregister_raw_message_handler.side_effect = _unregister
  47. # No-op for _request_version / request_status_update so the post-bind nudge doesn't crash.
  48. client._request_version = MagicMock()
  49. client.request_status_update = MagicMock()
  50. return client
  51. def _make_printer_manager(client) -> MagicMock:
  52. pm = MagicMock()
  53. pm.get_client = MagicMock(return_value=client)
  54. return pm
  55. def _make_bridge(server: SimpleMQTTServer, target: MagicMock | None = None) -> MQTTBridge:
  56. target = target if target is not None else _make_paho_client()
  57. pm = _make_printer_manager(target)
  58. return MQTTBridge(
  59. vp_id=1,
  60. vp_name="vp1",
  61. vp_serial=VP_SERIAL,
  62. target_printer_id=42,
  63. mqtt_server=server,
  64. printer_manager=pm,
  65. )
  66. # ---------------------------------------------------------------------------
  67. # Lifecycle
  68. # ---------------------------------------------------------------------------
  69. class TestBridgeLifecycle:
  70. @pytest.mark.asyncio
  71. async def test_start_registers_handler_on_target_client(self):
  72. target = _make_paho_client()
  73. bridge = _make_bridge(_make_server(), target)
  74. await bridge.start()
  75. assert len(target._raw_handlers) == 1
  76. assert bridge.is_active is True
  77. await bridge.stop()
  78. assert len(target._raw_handlers) == 0
  79. @pytest.mark.asyncio
  80. async def test_start_with_no_target_client_does_not_crash(self):
  81. pm = MagicMock()
  82. pm.get_client = MagicMock(return_value=None)
  83. bridge = MQTTBridge(
  84. vp_id=1,
  85. vp_name="vp1",
  86. vp_serial=VP_SERIAL,
  87. target_printer_id=42,
  88. mqtt_server=_make_server(),
  89. printer_manager=pm,
  90. )
  91. await bridge.start()
  92. assert bridge.is_active is False
  93. await bridge.stop()
  94. @pytest.mark.asyncio
  95. async def test_resolve_rebinds_when_paho_client_replaced(self):
  96. """BambuMQTTClient is destroyed and recreated on connect_printer; bridge must rebind."""
  97. old_client = _make_paho_client(serial="REAL_OLD")
  98. new_client = _make_paho_client(serial="REAL_NEW")
  99. pm = _make_printer_manager(old_client)
  100. bridge = MQTTBridge(
  101. vp_id=1,
  102. vp_name="vp1",
  103. vp_serial=VP_SERIAL,
  104. target_printer_id=42,
  105. mqtt_server=_make_server(),
  106. printer_manager=pm,
  107. )
  108. await bridge.start()
  109. assert len(old_client._raw_handlers) == 1
  110. assert bridge._target_serial == "REAL_OLD"
  111. pm.get_client.return_value = new_client
  112. bridge._resolve_client()
  113. assert len(old_client._raw_handlers) == 0
  114. assert len(new_client._raw_handlers) == 1
  115. assert bridge._target_serial == "REAL_NEW"
  116. await bridge.stop()
  117. @pytest.mark.asyncio
  118. async def test_post_bind_nudge_requests_version_and_status(self):
  119. target = _make_paho_client()
  120. bridge = _make_bridge(_make_server(), target)
  121. await bridge.start()
  122. target._request_version.assert_called_once()
  123. target.request_status_update.assert_called_once()
  124. await bridge.stop()
  125. # ---------------------------------------------------------------------------
  126. # Caching: push_status
  127. # ---------------------------------------------------------------------------
  128. class TestPushStatusCache:
  129. """push_status snapshots feed `_send_status_report` via the cache, not a fan-out."""
  130. @pytest.mark.asyncio
  131. async def test_push_status_is_cached_not_fanned_out(self):
  132. server = _make_server()
  133. server.push_raw_to_clients = AsyncMock()
  134. bridge = _make_bridge(server)
  135. await bridge.start()
  136. payload = json.dumps({"print": {"command": "push_status", "ams": {"ams": []}, "gcode_state": "IDLE"}}).encode()
  137. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", payload)
  138. await asyncio.sleep(0.01)
  139. server.push_raw_to_clients.assert_not_awaited()
  140. cached = bridge.get_latest_print_state()
  141. assert cached is not None
  142. assert cached["command"] == "push_status"
  143. assert cached["gcode_state"] == "IDLE"
  144. await bridge.stop()
  145. @pytest.mark.asyncio
  146. async def test_serial_rewritten_in_cached_push(self):
  147. server = _make_server()
  148. bridge = _make_bridge(server)
  149. await bridge.start()
  150. payload = json.dumps(
  151. {
  152. "print": {
  153. "command": "push_status",
  154. "upgrade_state": {"sn": H2D_SERIAL, "status": "IDLE"},
  155. }
  156. }
  157. ).encode()
  158. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", payload)
  159. await asyncio.sleep(0.01)
  160. cached = bridge.get_latest_print_state()
  161. assert cached["upgrade_state"]["sn"] == VP_SERIAL
  162. await bridge.stop()
  163. @pytest.mark.asyncio
  164. async def test_net_info_ip_rewritten_to_vp_ip(self):
  165. """BambuStudio reads `net.info[].ip` (LE uint32) for the FTP destination —
  166. must be rewritten to the VP's bind IP or the slicer bypasses the VP."""
  167. server = _make_server(bind_address=VP_IP)
  168. bridge = _make_bridge(server)
  169. await bridge.start()
  170. h2d_le = _ip_to_uint32_le(H2D_IP)
  171. vp_le = _ip_to_uint32_le(VP_IP)
  172. payload = json.dumps(
  173. {
  174. "print": {
  175. "command": "push_status",
  176. "net": {"info": [{"ip": h2d_le, "mask": 0xFFFFFF}, {"ip": 0, "mask": 0}]},
  177. }
  178. }
  179. ).encode()
  180. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", payload)
  181. await asyncio.sleep(0.01)
  182. cached = bridge.get_latest_print_state()
  183. assert cached["net"]["info"][0]["ip"] == vp_le
  184. assert cached["net"]["info"][1]["ip"] == 0 # untouched
  185. await bridge.stop()
  186. @pytest.mark.asyncio
  187. async def test_net_info_ip_rewritten_for_unknown_secondary_interface(self):
  188. """Regression for #1429: real printers (X1C / H2D Pro) report multiple
  189. active interfaces (WiFi + Ethernet) — only ONE matches the IP Bambuddy
  190. tracks. The rewrite must catch every non-zero entry, not just the one
  191. whose IP equals `_target_ip_uint32_le`, or the slicer's FTP fallback
  192. path leaks straight to the real printer."""
  193. server = _make_server(bind_address=VP_IP)
  194. bridge = _make_bridge(server)
  195. await bridge.start()
  196. h2d_le = _ip_to_uint32_le(H2D_IP)
  197. # A second IP Bambuddy never saw (e.g. printer's ethernet interface
  198. # while Bambuddy talks over wifi).
  199. other_le = _ip_to_uint32_le("192.168.99.42")
  200. vp_le = _ip_to_uint32_le(VP_IP)
  201. payload = json.dumps(
  202. {
  203. "print": {
  204. "command": "push_status",
  205. "net": {
  206. "info": [
  207. {"ip": h2d_le, "mask": 0xFFFFFF},
  208. {"ip": other_le, "mask": 0xFFFFFF},
  209. {"ip": 0, "mask": 0},
  210. ]
  211. },
  212. }
  213. }
  214. ).encode()
  215. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", payload)
  216. await asyncio.sleep(0.01)
  217. cached = bridge.get_latest_print_state()
  218. assert cached["net"]["info"][0]["ip"] == vp_le
  219. assert cached["net"]["info"][1]["ip"] == vp_le # secondary interface also rewritten
  220. assert cached["net"]["info"][2]["ip"] == 0 # placeholder untouched
  221. await bridge.stop()
  222. @pytest.mark.asyncio
  223. async def test_late_arriving_printer_ip_rewrites_existing_cache(self):
  224. """Regression for #1429: if the printer's `ip_address` is empty at
  225. first bind (DB row stale, or the client object exists before the
  226. first SSDP refresh fills it in), the rewrite stays disabled and the
  227. first cached push poisons the cache with the real-printer IP.
  228. Once `ip_address` becomes valid, the next refresh tick must (a) arm
  229. the encoding and (b) sweep the cached `net.info[].ip` so the slicer
  230. sees the rewritten value on its next pull. Without the sweep the
  231. sticky-key preservation keeps the poisoned value alive across
  232. every subsequent incremental push."""
  233. server = _make_server(bind_address=VP_IP)
  234. # Bind to a client whose ip_address is empty at start — simulates the
  235. # late-arrival path.
  236. target = _make_paho_client(ip="")
  237. bridge = _make_bridge(server, target)
  238. await bridge.start()
  239. assert bridge._target_ip_uint32_le is None # not yet armed
  240. h2d_le = _ip_to_uint32_le(H2D_IP)
  241. vp_le = _ip_to_uint32_le(VP_IP)
  242. payload = json.dumps(
  243. {
  244. "print": {
  245. "command": "push_status",
  246. "net": {"info": [{"ip": h2d_le, "mask": 0xFFFFFF}]},
  247. }
  248. }
  249. ).encode()
  250. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", payload)
  251. await asyncio.sleep(0.01)
  252. # First push landed before encoding was armed → cache holds real IP.
  253. cached = bridge.get_latest_print_state()
  254. assert cached["net"]["info"][0]["ip"] == h2d_le
  255. # Printer's IP becomes known. Next refresh tick must self-heal.
  256. target.ip_address = H2D_IP
  257. bridge._resolve_client()
  258. cached = bridge.get_latest_print_state()
  259. assert cached["net"]["info"][0]["ip"] == vp_le, (
  260. "cache must be swept once encoding becomes valid; sticky-key "
  261. "preservation would otherwise keep the poisoned IP forever"
  262. )
  263. assert bridge._target_ip_uint32_le == h2d_le
  264. await bridge.stop()
  265. @pytest.mark.asyncio
  266. async def test_request_topic_message_is_ignored(self):
  267. server = _make_server()
  268. bridge = _make_bridge(server)
  269. await bridge.start()
  270. payload = json.dumps({"print": {"command": "push_status"}}).encode()
  271. bridge._on_printer_raw(f"device/{H2D_SERIAL}/request", payload)
  272. await asyncio.sleep(0.01)
  273. assert bridge.get_latest_print_state() is None
  274. await bridge.stop()
  275. @pytest.mark.asyncio
  276. async def test_incremental_push_preserves_ams_from_previous_cache(self):
  277. """Regression for #1371: Bambu firmware sends FULL push_status on
  278. pushall (with AMS/vt_tray/net/etc.) but typically OMITS those fields
  279. from 1 Hz incremental push_status updates. Without preserving the
  280. sticky keys across pushes, the cache forgets AMS info after the first
  281. incremental update, and BambuStudio (which reads the cache via the
  282. VP's 1 Hz status push) sees no AMS info until the user power-cycles
  283. the printer (forcing a fresh pushall).
  284. """
  285. server = _make_server()
  286. bridge = _make_bridge(server)
  287. await bridge.start()
  288. # 1. Initial pushall response with full state, AMS included.
  289. full_push = json.dumps(
  290. {
  291. "print": {
  292. "command": "push_status",
  293. "gcode_state": "IDLE",
  294. "wifi_signal": "-50dBm",
  295. "ams": {
  296. "ams": [
  297. {
  298. "id": "0",
  299. "tray": [
  300. {"id": "0", "tray_type": "PLA", "tray_color": "FF0000FF"},
  301. {"id": "1", "tray_type": "PETG", "tray_color": "00FF00FF"},
  302. ],
  303. }
  304. ],
  305. "tray_exist_bits": "3",
  306. },
  307. "vt_tray": {"id": "254", "tray_type": ""},
  308. "lights_report": [{"node": "chamber_light", "mode": "on"}],
  309. }
  310. }
  311. ).encode()
  312. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", full_push)
  313. await asyncio.sleep(0.01)
  314. cached = bridge.get_latest_print_state()
  315. assert cached["ams"]["ams"][0]["tray"][0]["tray_type"] == "PLA"
  316. assert cached["vt_tray"]["id"] == "254"
  317. assert cached["lights_report"][0]["mode"] == "on"
  318. # 2. Incremental push with only temp/wifi changes — NO ams field.
  319. # This is what the printer sends every ~1 s between full pushalls.
  320. incremental_push = json.dumps(
  321. {
  322. "print": {
  323. "command": "push_status",
  324. "wifi_signal": "-55dBm",
  325. "chamber_temper": 26.0,
  326. }
  327. }
  328. ).encode()
  329. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", incremental_push)
  330. await asyncio.sleep(0.01)
  331. cached = bridge.get_latest_print_state()
  332. # New fields take effect.
  333. assert cached["wifi_signal"] == "-55dBm"
  334. assert cached["chamber_temper"] == 26.0
  335. # Sticky fields preserved from the previous cache (the #1371 fix).
  336. assert "ams" in cached, "AMS field must be preserved across incremental pushes (#1371)"
  337. assert cached["ams"]["ams"][0]["tray"][0]["tray_type"] == "PLA"
  338. assert cached["ams"]["tray_exist_bits"] == "3"
  339. assert cached["vt_tray"]["id"] == "254"
  340. assert cached["lights_report"][0]["mode"] == "on"
  341. await bridge.stop()
  342. @pytest.mark.asyncio
  343. async def test_partial_ams_status_update_preserves_unit_list(self):
  344. """#1387: Bambu firmware also sends `ams` updates where the key is
  345. present but the inner `ams` array is missing — e.g. just
  346. ``{ams_status: 1}`` or a humidity change. Before the deep-merge fix
  347. the bridge would overwrite the cached AMS with this stripped blob,
  348. the slicer would read it on the next 1 Hz push, and BambuStudio
  349. would drop the unit list and fall back to its "no AMS" render
  350. (only the external spool visible — the reporter's exact symptom).
  351. Now the partial update only mutates the fields it carries; the
  352. cached unit list survives.
  353. """
  354. server = _make_server()
  355. bridge = _make_bridge(server)
  356. await bridge.start()
  357. # 1. Pushall with full AMS state.
  358. bridge._on_printer_raw(
  359. f"device/{H2D_SERIAL}/report",
  360. json.dumps(
  361. {
  362. "print": {
  363. "command": "push_status",
  364. "ams": {
  365. "ams": [
  366. {
  367. "id": "0",
  368. "humidity": "1",
  369. "tray": [{"id": "0", "tray_type": "PLA", "tray_color": "FF0000FF"}],
  370. }
  371. ],
  372. "tray_exist_bits": "1",
  373. "ams_status": "0",
  374. },
  375. }
  376. }
  377. ).encode(),
  378. )
  379. await asyncio.sleep(0.01)
  380. # 2. Partial AMS update — only `ams_status` and `humidity` changed.
  381. # No `ams.ams` array, so prev's unit list must be preserved.
  382. bridge._on_printer_raw(
  383. f"device/{H2D_SERIAL}/report",
  384. json.dumps(
  385. {
  386. "print": {
  387. "command": "push_status",
  388. "ams": {"ams_status": "1", "humidity": "2"},
  389. }
  390. }
  391. ).encode(),
  392. )
  393. await asyncio.sleep(0.01)
  394. cached = bridge.get_latest_print_state()
  395. # Scalar fields take the new values.
  396. assert cached["ams"]["ams_status"] == "1"
  397. assert cached["ams"]["humidity"] == "2"
  398. # Unit + tray data preserved from the pushall.
  399. assert cached["ams"]["tray_exist_bits"] == "1"
  400. assert len(cached["ams"]["ams"]) == 1
  401. assert cached["ams"]["ams"][0]["tray"][0]["tray_type"] == "PLA"
  402. assert cached["ams"]["ams"][0]["tray"][0]["tray_color"] == "FF0000FF"
  403. await bridge.stop()
  404. @pytest.mark.asyncio
  405. async def test_partial_ams_unit_update_preserves_other_units(self):
  406. """#1387: when multiple AMS units are configured (e.g. H2D with two
  407. AMS), an incremental push during a print typically only carries the
  408. unit / tray that changed state. Naive replacement of `ams.ams` wipes
  409. the other unit. The bridge merges unit-by-unit by id, preserving
  410. units the incremental doesn't mention.
  411. """
  412. server = _make_server()
  413. bridge = _make_bridge(server)
  414. await bridge.start()
  415. # 1. Pushall with two AMS units configured.
  416. bridge._on_printer_raw(
  417. f"device/{H2D_SERIAL}/report",
  418. json.dumps(
  419. {
  420. "print": {
  421. "command": "push_status",
  422. "ams": {
  423. "ams": [
  424. {"id": "0", "tray": [{"id": "0", "tray_type": "PLA"}]},
  425. {"id": "1", "tray": [{"id": "0", "tray_type": "PETG"}]},
  426. ],
  427. "tray_exist_bits": "3",
  428. },
  429. }
  430. }
  431. ).encode(),
  432. )
  433. await asyncio.sleep(0.01)
  434. # 2. Tray-targeted incremental: unit 0 / tray 0 state changed.
  435. # Unit 1 is not in the update — must survive.
  436. bridge._on_printer_raw(
  437. f"device/{H2D_SERIAL}/report",
  438. json.dumps(
  439. {
  440. "print": {
  441. "command": "push_status",
  442. "ams": {"ams": [{"id": "0", "tray": [{"id": "0", "state": "11"}]}]},
  443. }
  444. }
  445. ).encode(),
  446. )
  447. await asyncio.sleep(0.01)
  448. cached = bridge.get_latest_print_state()
  449. units = {u["id"]: u for u in cached["ams"]["ams"]}
  450. # Unit 0 keeps its tray_type from the pushall + picks up the new state.
  451. assert units["0"]["tray"][0]["tray_type"] == "PLA"
  452. assert units["0"]["tray"][0]["state"] == "11"
  453. # Unit 1 survives the incremental.
  454. assert "1" in units
  455. assert units["1"]["tray"][0]["tray_type"] == "PETG"
  456. await bridge.stop()
  457. @pytest.mark.asyncio
  458. async def test_partial_ams_tray_update_preserves_other_trays(self):
  459. """Same shape as the unit-level test but at the tray level. AMS
  460. unit 0 has four trays; the incremental only mentions tray 0.
  461. Trays 1-3 must survive intact."""
  462. server = _make_server()
  463. bridge = _make_bridge(server)
  464. await bridge.start()
  465. bridge._on_printer_raw(
  466. f"device/{H2D_SERIAL}/report",
  467. json.dumps(
  468. {
  469. "print": {
  470. "command": "push_status",
  471. "ams": {
  472. "ams": [
  473. {
  474. "id": "0",
  475. "tray": [
  476. {"id": "0", "tray_type": "PLA", "tray_color": "FF0000FF"},
  477. {"id": "1", "tray_type": "PETG", "tray_color": "00FF00FF"},
  478. {"id": "2", "tray_type": "ABS", "tray_color": "0000FFFF"},
  479. {"id": "3", "tray_type": "TPU", "tray_color": "FFFF00FF"},
  480. ],
  481. }
  482. ],
  483. },
  484. }
  485. }
  486. ).encode(),
  487. )
  488. await asyncio.sleep(0.01)
  489. bridge._on_printer_raw(
  490. f"device/{H2D_SERIAL}/report",
  491. json.dumps(
  492. {
  493. "print": {
  494. "command": "push_status",
  495. "ams": {"ams": [{"id": "0", "tray": [{"id": "0", "state": "11"}]}]},
  496. }
  497. }
  498. ).encode(),
  499. )
  500. await asyncio.sleep(0.01)
  501. cached = bridge.get_latest_print_state()
  502. trays = {t["id"]: t for t in cached["ams"]["ams"][0]["tray"]}
  503. assert trays["0"]["tray_type"] == "PLA"
  504. assert trays["0"]["state"] == "11"
  505. # Trays not mentioned in the incremental survive intact.
  506. assert trays["1"]["tray_type"] == "PETG"
  507. assert trays["2"]["tray_type"] == "ABS"
  508. assert trays["3"]["tray_type"] == "TPU"
  509. await bridge.stop()
  510. @pytest.mark.asyncio
  511. async def test_incoming_ams_update_replaces_cached_ams(self):
  512. """Counterpart to the #1371 fix: preservation only kicks in when the
  513. incoming push OMITS a sticky key. When the printer DOES send a fresh
  514. `ams` value (e.g. on a pushall, or when AMS state genuinely changes),
  515. that value must take effect — the preservation must not shadow real
  516. updates.
  517. """
  518. server = _make_server()
  519. bridge = _make_bridge(server)
  520. await bridge.start()
  521. # 1. Initial state: PLA in tray 0.
  522. bridge._on_printer_raw(
  523. f"device/{H2D_SERIAL}/report",
  524. json.dumps(
  525. {
  526. "print": {
  527. "command": "push_status",
  528. "ams": {"ams": [{"id": "0", "tray": [{"id": "0", "tray_type": "PLA"}]}]},
  529. }
  530. }
  531. ).encode(),
  532. )
  533. await asyncio.sleep(0.01)
  534. # 2. Fresh push with PETG — must replace, not get shadowed by the old PLA.
  535. bridge._on_printer_raw(
  536. f"device/{H2D_SERIAL}/report",
  537. json.dumps(
  538. {
  539. "print": {
  540. "command": "push_status",
  541. "ams": {"ams": [{"id": "0", "tray": [{"id": "0", "tray_type": "PETG"}]}]},
  542. }
  543. }
  544. ).encode(),
  545. )
  546. await asyncio.sleep(0.01)
  547. cached = bridge.get_latest_print_state()
  548. assert cached["ams"]["ams"][0]["tray"][0]["tray_type"] == "PETG"
  549. await bridge.stop()
  550. # ---------------------------------------------------------------------------
  551. # Caching: get_version response
  552. # ---------------------------------------------------------------------------
  553. class TestVersionCache:
  554. @pytest.mark.asyncio
  555. async def test_get_version_response_caches_modules(self):
  556. server = _make_server()
  557. bridge = _make_bridge(server)
  558. await bridge.start()
  559. payload = json.dumps(
  560. {
  561. "info": {
  562. "command": "get_version",
  563. "module": [
  564. {"name": "ota", "sn": H2D_SERIAL, "sw_ver": "01.03.00.00"},
  565. {"name": "n3f/0", "sn": "AMS_HW_1", "sw_ver": "04.00.21.87"},
  566. ],
  567. }
  568. }
  569. ).encode()
  570. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", payload)
  571. await asyncio.sleep(0.01)
  572. modules = bridge.get_latest_version_modules()
  573. assert modules is not None
  574. assert len(modules) == 2
  575. # Device-level sn rewritten; AMS-hardware sn left alone.
  576. assert modules[0]["sn"] == VP_SERIAL
  577. assert modules[1]["sn"] == "AMS_HW_1"
  578. await bridge.stop()
  579. # ---------------------------------------------------------------------------
  580. # Selective fan-out (everything that's not push_status / get_version)
  581. # ---------------------------------------------------------------------------
  582. class TestCommandResponseFanout:
  583. @pytest.mark.asyncio
  584. async def test_extrusion_cali_get_response_is_fanned_out(self):
  585. """Slicer's extrusion_cali_get goes to the printer; the printer's response
  586. must reach the slicer or BambuStudio's pre-flight blocks Send."""
  587. server = _make_server()
  588. server.push_raw_to_clients = AsyncMock()
  589. bridge = _make_bridge(server)
  590. await bridge.start()
  591. body = json.dumps({"print": {"command": "extrusion_cali_get", "filaments": []}}).encode()
  592. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", body)
  593. await asyncio.sleep(0.01)
  594. server.push_raw_to_clients.assert_awaited_once()
  595. topic, _payload = server.push_raw_to_clients.await_args.args
  596. assert topic == f"device/{VP_SERIAL}/report"
  597. await bridge.stop()
  598. # ---------------------------------------------------------------------------
  599. # Forwarding: slicer → printer
  600. # ---------------------------------------------------------------------------
  601. class TestForwardToPrinter:
  602. @pytest.mark.asyncio
  603. async def test_forward_publishes_to_real_serial_request_topic(self):
  604. target = _make_paho_client()
  605. bridge = _make_bridge(_make_server(), target)
  606. await bridge.start()
  607. ok = bridge.forward_to_printer({"print": {"command": "stop"}})
  608. assert ok is True
  609. target.publish_raw.assert_called_once()
  610. topic, payload = target.publish_raw.call_args.args
  611. assert topic == f"device/{H2D_SERIAL}/request"
  612. assert json.loads(payload) == {"print": {"command": "stop"}}
  613. await bridge.stop()
  614. @pytest.mark.asyncio
  615. async def test_forward_returns_false_when_not_bound(self):
  616. pm = MagicMock()
  617. pm.get_client = MagicMock(return_value=None)
  618. bridge = MQTTBridge(
  619. vp_id=1,
  620. vp_name="vp1",
  621. vp_serial=VP_SERIAL,
  622. target_printer_id=42,
  623. mqtt_server=_make_server(),
  624. printer_manager=pm,
  625. )
  626. await bridge.start()
  627. assert bridge.forward_to_printer({"print": {"command": "stop"}}) is False
  628. await bridge.stop()
  629. # ---------------------------------------------------------------------------
  630. # SimpleMQTTServer status response: cached-as-base
  631. # ---------------------------------------------------------------------------
  632. class TestStatusReportCachedAsBase:
  633. """`_send_status_report` sends near-byte-identical real data when bridge cache exists."""
  634. def _capture_published(self, server: SimpleMQTTServer):
  635. """Wrap _publish_to_report to capture (topic, payload_dict)."""
  636. published: list = []
  637. async def _capture(writer, payload, serial=""):
  638. published.append((serial or server.serial, payload))
  639. server._publish_to_report = _capture # type: ignore[assignment]
  640. return published
  641. @pytest.mark.asyncio
  642. async def test_uses_real_cache_when_bridge_active(self):
  643. server = _make_server()
  644. bridge = MagicMock()
  645. bridge.get_latest_print_state.return_value = {
  646. "command": "push_status",
  647. "msg": 0,
  648. "ams": {"ams": [{"id": "0"}]},
  649. "device": {"extruder": {"info": [{"id": 0}, {"id": 1}]}},
  650. "nozzle_diameter": "0.4",
  651. "nozzle_type": "HH01", # real H2D value, not synthetic 'hardened_steel'
  652. }
  653. server.set_bridge(bridge)
  654. published = self._capture_published(server)
  655. await server._send_status_report(MagicMock())
  656. assert len(published) == 1
  657. _serial, payload = published[0]
  658. # AMS / device / nozzle_type all from cache
  659. assert payload["print"]["nozzle_type"] == "HH01"
  660. assert payload["print"]["device"]["extruder"]["info"][1]["id"] == 1
  661. # Protocol fields under our control
  662. assert payload["print"]["command"] == "push_status"
  663. assert payload["print"]["gcode_state"] == "IDLE"
  664. @pytest.mark.asyncio
  665. async def test_falls_back_to_synthetic_when_no_cache(self):
  666. server = _make_server()
  667. bridge = MagicMock()
  668. bridge.get_latest_print_state.return_value = None
  669. server.set_bridge(bridge)
  670. published = self._capture_published(server)
  671. await server._send_status_report(MagicMock())
  672. assert len(published) == 1
  673. _serial, payload = published[0]
  674. # Synthetic baseline has stub fields like nozzle_type='hardened_steel'
  675. # and a `storage` field that the real H2D doesn't push.
  676. assert payload["print"]["nozzle_type"] == "hardened_steel"
  677. assert "storage" in payload["print"]
  678. @pytest.mark.asyncio
  679. async def test_storage_indicators_overlaid_for_send_preflight(self):
  680. """#1228: P1S/A1-class firmware doesn't always include the SD/storage
  681. fields BambuStudio's "Send" pre-flight reads. Without these the
  682. slicer rejects with 'storage needs to be inserted' before even
  683. attempting FTP. The cached-as-base path now overlays them so the
  684. pre-flight passes regardless of what the real printer reports.
  685. """
  686. server = _make_server()
  687. bridge = MagicMock()
  688. # Real P1S push without SD card inserted: home_flag has other bits set
  689. # but the SD bit (0x100) is clear; sdcard is False; no storage field.
  690. bridge.get_latest_print_state.return_value = {
  691. "command": "push_status",
  692. "msg": 0,
  693. "home_flag": 0x42,
  694. "sdcard": False,
  695. }
  696. server.set_bridge(bridge)
  697. published = self._capture_published(server)
  698. await server._send_status_report(MagicMock())
  699. _serial, payload = published[0]
  700. # SD bit ORed onto whatever was there — other bits preserved.
  701. assert payload["print"]["home_flag"] & 0x100 == 0x100
  702. assert payload["print"]["home_flag"] & 0x42 == 0x42
  703. # Force-set so a False from the printer doesn't trip the pre-flight.
  704. assert payload["print"]["sdcard"] is True
  705. # storage was missing — the overlay must inject a non-empty default.
  706. assert "storage" in payload["print"]
  707. assert payload["print"]["storage"]["free"] > 0
  708. assert payload["print"]["storage"]["total"] > 0
  709. @pytest.mark.asyncio
  710. async def test_storage_indicators_preserve_real_storage_when_present(self):
  711. """When the real printer DOES report a storage block, pass it through
  712. unchanged (the overlay only fills in the missing field, not overrides).
  713. """
  714. server = _make_server()
  715. bridge = MagicMock()
  716. real_storage = {"free": 12345, "total": 67890}
  717. bridge.get_latest_print_state.return_value = {
  718. "command": "push_status",
  719. "msg": 0,
  720. "home_flag": 0x100, # SD bit already set on the real printer
  721. "sdcard": True,
  722. "storage": real_storage,
  723. }
  724. server.set_bridge(bridge)
  725. published = self._capture_published(server)
  726. await server._send_status_report(MagicMock())
  727. _serial, payload = published[0]
  728. # SD bit OR is idempotent — already-set bit stays set.
  729. assert payload["print"]["home_flag"] == 0x100
  730. assert payload["print"]["sdcard"] is True
  731. # Real values pass through, NOT the synthetic defaults.
  732. assert payload["print"]["storage"] == real_storage
  733. @pytest.mark.asyncio
  734. async def test_overrides_protocol_fields_even_when_cache_present(self):
  735. """Cached value's gcode_state must NOT win over our local upload-state-machine value."""
  736. server = _make_server()
  737. server._gcode_state = "PREPARE"
  738. server._current_file = "foo.3mf"
  739. bridge = MagicMock()
  740. bridge.get_latest_print_state.return_value = {
  741. "command": "push_status",
  742. "gcode_state": "IDLE", # printer is idle; we are mid-FTP-upload
  743. "gcode_file": "",
  744. "gcode_file_prepare_percent": "0",
  745. }
  746. server.set_bridge(bridge)
  747. published = self._capture_published(server)
  748. await server._send_status_report(MagicMock())
  749. _serial, payload = published[0]
  750. assert payload["print"]["gcode_state"] == "PREPARE"
  751. assert payload["print"]["gcode_file"] == "foo.3mf"
  752. @pytest.mark.asyncio
  753. async def test_live_progress_fields_zeroed_in_cached_branch(self):
  754. """#1558: when the real target printer is mid-print, the cached
  755. push_status carries live values for mc_percent / stg_cur / layer_num /
  756. etc. BambuStudio's Send pre-flight reads any of these as "VP busy"
  757. even when gcode_state above is forced to IDLE — blocking Send while
  758. the target prints. The cached branch must override these to the same
  759. idle values the synthetic stub uses.
  760. """
  761. server = _make_server()
  762. bridge = MagicMock()
  763. # Real printer mid-print state: gcode_state may be RUNNING upstream,
  764. # but the VP's own _gcode_state is IDLE (Send is requesting a
  765. # new upload, the VP isn't running anything).
  766. bridge.get_latest_print_state.return_value = {
  767. "command": "push_status",
  768. "msg": 0,
  769. "gcode_state": "RUNNING",
  770. "mc_print_stage": "2",
  771. "mc_percent": 47,
  772. "mc_remaining_time": 3600,
  773. "stg": [1, 2, 3],
  774. "stg_cur": 14,
  775. "layer_num": 120,
  776. "total_layer_num": 250,
  777. "print_error": 0,
  778. }
  779. server.set_bridge(bridge)
  780. published = self._capture_published(server)
  781. await server._send_status_report(MagicMock())
  782. _serial, payload = published[0]
  783. # Every live-progress field must reflect "idle / VP isn't busy".
  784. assert payload["print"]["mc_print_stage"] == ""
  785. assert payload["print"]["mc_percent"] == 0
  786. assert payload["print"]["mc_remaining_time"] == 0
  787. assert payload["print"]["stg"] == []
  788. assert payload["print"]["stg_cur"] == 0
  789. assert payload["print"]["layer_num"] == 0
  790. assert payload["print"]["total_layer_num"] == 0
  791. assert payload["print"]["print_error"] == 0
  792. # ---------------------------------------------------------------------------
  793. # Wire format
  794. # ---------------------------------------------------------------------------
  795. class TestWireFormat:
  796. """BambuStudio's Send pre-flight rejects compact JSON — must match real printer's
  797. indented format (32K bytes for an idle H2D vs 14K compact)."""
  798. @pytest.mark.asyncio
  799. async def test_publish_uses_indent_4_json_format(self):
  800. server = _make_server()
  801. captured: list = []
  802. async def _capture_drain():
  803. pass
  804. writer = MagicMock()
  805. writer.write = lambda data: captured.append(data)
  806. writer.drain = AsyncMock()
  807. await server._publish_to_report(writer, {"print": {"command": "push_status", "ams": {}}})
  808. body = b"".join(captured)
  809. assert b'\n "print"' in body, "publish_to_report must use indent=4 JSON"
  810. # ---------------------------------------------------------------------------
  811. # Routing: _handle_publish
  812. # ---------------------------------------------------------------------------
  813. class TestPublishRouting:
  814. """Slicer-issued commands: project_file/gcode_file handled locally, everything
  815. else forwarded to the real printer."""
  816. def _build_publish_payload(self, topic: str, body: bytes) -> bytes:
  817. topic_bytes = topic.encode("utf-8")
  818. return bytes([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF]) + topic_bytes + body
  819. def _attach_active_bridge(self, server: SimpleMQTTServer) -> MagicMock:
  820. bridge = MagicMock()
  821. bridge.is_active = True
  822. bridge.forward_to_printer = MagicMock(return_value=True)
  823. server.set_bridge(bridge)
  824. return bridge
  825. @pytest.mark.asyncio
  826. async def test_project_file_handled_locally_not_forwarded(self):
  827. server = _make_server()
  828. bridge = self._attach_active_bridge(server)
  829. writer = MagicMock()
  830. writer.write = MagicMock()
  831. writer.drain = AsyncMock()
  832. body = json.dumps({"print": {"command": "project_file", "subtask_name": "f", "sequence_id": "1"}}).encode()
  833. payload = self._build_publish_payload(f"device/{VP_SERIAL}/request", body)
  834. with patch.object(server, "_send_print_response", new=AsyncMock()) as mock_resp:
  835. await server._handle_publish(0x30, payload, writer, "client1")
  836. bridge.forward_to_printer.assert_not_called()
  837. mock_resp.assert_awaited_once()
  838. @pytest.mark.asyncio
  839. async def test_gcode_file_handled_locally_not_forwarded(self):
  840. server = _make_server()
  841. bridge = self._attach_active_bridge(server)
  842. writer = MagicMock()
  843. writer.write = MagicMock()
  844. writer.drain = AsyncMock()
  845. body = json.dumps({"print": {"command": "gcode_file", "subtask_name": "f.gcode", "sequence_id": "1"}}).encode()
  846. payload = self._build_publish_payload(f"device/{VP_SERIAL}/request", body)
  847. with patch.object(server, "_send_print_response", new=AsyncMock()):
  848. await server._handle_publish(0x30, payload, writer, "client1")
  849. bridge.forward_to_printer.assert_not_called()
  850. @pytest.mark.asyncio
  851. async def test_pushall_handled_locally_not_forwarded(self):
  852. server = _make_server()
  853. bridge = self._attach_active_bridge(server)
  854. writer = MagicMock()
  855. writer.write = MagicMock()
  856. writer.drain = AsyncMock()
  857. body = json.dumps({"pushing": {"command": "pushall", "sequence_id": "0"}}).encode()
  858. payload = self._build_publish_payload(f"device/{VP_SERIAL}/request", body)
  859. with patch.object(server, "_send_status_report", new=AsyncMock()) as mock_status:
  860. await server._handle_publish(0x30, payload, writer, "client1")
  861. # Synthetic answer fires (fast, low latency); no forwarding (the
  862. # cache already mirrors what the printer would respond with).
  863. bridge.forward_to_printer.assert_not_called()
  864. mock_status.assert_awaited_once()
  865. @pytest.mark.asyncio
  866. async def test_get_version_handled_locally_not_forwarded(self):
  867. server = _make_server()
  868. bridge = self._attach_active_bridge(server)
  869. writer = MagicMock()
  870. writer.write = MagicMock()
  871. writer.drain = AsyncMock()
  872. body = json.dumps({"info": {"command": "get_version", "sequence_id": "1"}}).encode()
  873. payload = self._build_publish_payload(f"device/{VP_SERIAL}/request", body)
  874. with patch.object(server, "_send_version_response", new=AsyncMock()) as mock_ver:
  875. await server._handle_publish(0x30, payload, writer, "client1")
  876. bridge.forward_to_printer.assert_not_called()
  877. mock_ver.assert_awaited_once()
  878. @pytest.mark.asyncio
  879. async def test_extrusion_cali_get_is_forwarded(self):
  880. """extrusion_cali_get fetches per-filament k-profiles — must reach the printer."""
  881. server = _make_server()
  882. bridge = self._attach_active_bridge(server)
  883. writer = MagicMock()
  884. writer.write = MagicMock()
  885. writer.drain = AsyncMock()
  886. body = json.dumps(
  887. {
  888. "print": {
  889. "command": "extrusion_cali_get",
  890. "filament_id": "",
  891. "nozzle_diameter": "0.4",
  892. "sequence_id": "5",
  893. }
  894. }
  895. ).encode()
  896. payload = self._build_publish_payload(f"device/{VP_SERIAL}/request", body)
  897. await server._handle_publish(0x30, payload, writer, "client1")
  898. bridge.forward_to_printer.assert_called_once()
  899. forwarded = bridge.forward_to_printer.call_args.args[0]
  900. assert forwarded["print"]["command"] == "extrusion_cali_get"
  901. @pytest.mark.asyncio
  902. async def test_print_stop_is_forwarded(self):
  903. server = _make_server()
  904. bridge = self._attach_active_bridge(server)
  905. writer = MagicMock()
  906. writer.write = MagicMock()
  907. writer.drain = AsyncMock()
  908. body = json.dumps({"print": {"command": "stop", "sequence_id": "5"}}).encode()
  909. payload = self._build_publish_payload(f"device/{VP_SERIAL}/request", body)
  910. await server._handle_publish(0x30, payload, writer, "client1")
  911. bridge.forward_to_printer.assert_called_once()
  912. # ---------------------------------------------------------------------------
  913. # IP encoding helper
  914. # ---------------------------------------------------------------------------
  915. class TestIpEncoding:
  916. def test_le_uint32_matches_real_h2d_capture(self):
  917. # 192.168.255.133 captured from real H2D's net.info[0].ip = 2248124608
  918. assert _ip_to_uint32_le("192.168.255.133") == 2248124608
  919. def test_vp_ip_round_trip(self):
  920. assert _ip_to_uint32_le("192.168.255.16") == 285190336
  921. def test_invalid_ip_raises(self):
  922. with pytest.raises(ValueError):
  923. _ip_to_uint32_le("not.an.ip.actually")
  924. # ---------------------------------------------------------------------------
  925. # Auto-resolve fallback for default-config (bind_address = "0.0.0.0")
  926. # ---------------------------------------------------------------------------
  927. class TestBindAddressAutoResolve:
  928. """#1429 residual: VPs created without a dedicated bind IP run on
  929. `bind_address=0.0.0.0`. The original fix's `_refresh_ip_encoding`
  930. early-returned on 0.0.0.0, so the rewrite never armed and `net.info[].ip`
  931. kept leaking the real printer IP. Now the bridge auto-resolves a host
  932. interface in the printer's subnet and uses that as the VP IP."""
  933. @pytest.mark.asyncio
  934. async def test_rewrite_arms_via_auto_resolved_host_ip(self):
  935. """When bind_address is 0.0.0.0, fall back to the host interface in
  936. the target printer's subnet and rewrite to that IP."""
  937. server = _make_server(bind_address="0.0.0.0")
  938. bridge = _make_bridge(server)
  939. with patch(
  940. "backend.app.services.virtual_printer.mqtt_bridge._resolve_host_interface_for_target",
  941. return_value=VP_IP,
  942. ):
  943. await bridge.start()
  944. h2d_le = _ip_to_uint32_le(H2D_IP)
  945. vp_le = _ip_to_uint32_le(VP_IP)
  946. payload = json.dumps(
  947. {
  948. "print": {
  949. "command": "push_status",
  950. "net": {"info": [{"ip": h2d_le, "mask": 0xFFFFFF}]},
  951. }
  952. }
  953. ).encode()
  954. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", payload)
  955. await asyncio.sleep(0.01)
  956. cached = bridge.get_latest_print_state()
  957. assert cached["net"]["info"][0]["ip"] == vp_le
  958. assert bridge._vp_ip_uint32_le == vp_le
  959. await bridge.stop()
  960. @pytest.mark.asyncio
  961. async def test_rewrite_disabled_when_no_matching_host_interface(self):
  962. """If no host interface shares a subnet with the printer, the bridge
  963. cannot pick a sensible VP IP — leave encoding unarmed and let the
  964. push through unrewritten (no crash, no wrong rewrite)."""
  965. server = _make_server(bind_address="")
  966. bridge = _make_bridge(server)
  967. with patch(
  968. "backend.app.services.virtual_printer.mqtt_bridge._resolve_host_interface_for_target",
  969. return_value=None,
  970. ):
  971. await bridge.start()
  972. h2d_le = _ip_to_uint32_le(H2D_IP)
  973. payload = json.dumps(
  974. {
  975. "print": {
  976. "command": "push_status",
  977. "net": {"info": [{"ip": h2d_le, "mask": 0xFFFFFF}]},
  978. }
  979. }
  980. ).encode()
  981. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", payload)
  982. await asyncio.sleep(0.01)
  983. assert bridge._vp_ip_uint32_le is None
  984. assert bridge._target_ip_uint32_le is None
  985. await bridge.stop()
  986. @pytest.mark.asyncio
  987. async def test_explicit_bind_ip_takes_precedence_over_auto_resolve(self):
  988. """Auto-resolve only kicks in when bind_address is empty/0.0.0.0; an
  989. explicitly-set bind IP must be used verbatim even if there's also a
  990. same-subnet host interface."""
  991. server = _make_server(bind_address=VP_IP)
  992. bridge = _make_bridge(server)
  993. # Auto-resolver would have returned a DIFFERENT IP — we must not use it.
  994. with patch(
  995. "backend.app.services.virtual_printer.mqtt_bridge._resolve_host_interface_for_target",
  996. return_value="10.99.99.99",
  997. ):
  998. await bridge.start()
  999. assert bridge._vp_ip_uint32_le == _ip_to_uint32_le(VP_IP)
  1000. await bridge.stop()
  1001. def test_resolve_helper_returns_none_for_unreachable_target(self):
  1002. """The helper itself must be defensive — if `find_interface_for_ip`
  1003. raises or returns None, we get None (no crash)."""
  1004. with patch(
  1005. "backend.app.services.network_utils.find_interface_for_ip",
  1006. return_value=None,
  1007. ):
  1008. assert _resolve_host_interface_for_target("203.0.113.1") is None