test_vp_mqtt_bridge.py 79 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952
  1. """Tests for the VP MQTT bridge — non-proxy mirror of target printer state to slicer."""
  2. import asyncio
  3. import json
  4. import logging
  5. import socket
  6. import time
  7. from pathlib import Path
  8. from unittest.mock import AsyncMock, MagicMock, patch
  9. import pytest
  10. from backend.app.services.virtual_printer.mqtt_bridge import (
  11. MQTTBridge,
  12. _ip_to_uint32_le,
  13. _resolve_host_interface_for_target,
  14. _resolve_target_to_ipv4,
  15. )
  16. from backend.app.services.virtual_printer.mqtt_server import (
  17. _UPLOAD_SETTLE_SECONDS,
  18. SimpleMQTTServer,
  19. )
  20. H2D_SERIAL = "0948BB540200427"
  21. VP_SERIAL = "09400A391800003"
  22. H2D_IP = "192.168.255.133"
  23. VP_IP = "192.168.255.16"
  24. def _make_server(serial: str = VP_SERIAL, bind_address: str = VP_IP) -> SimpleMQTTServer:
  25. return SimpleMQTTServer(
  26. serial=serial,
  27. access_code="deadbeef",
  28. cert_path=Path("/tmp/unused.crt"), # nosec B108
  29. key_path=Path("/tmp/unused.key"), # nosec B108
  30. model="O1D",
  31. bind_address=bind_address,
  32. )
  33. def _make_paho_client(
  34. serial: str = H2D_SERIAL,
  35. ip: str = H2D_IP,
  36. *,
  37. connected: bool = True,
  38. ) -> MagicMock:
  39. """Build a mock BambuMQTTClient that satisfies MQTTBridge's interface."""
  40. client = MagicMock()
  41. client.serial_number = serial
  42. client.ip_address = ip
  43. client.state = MagicMock()
  44. client.state.connected = connected
  45. client.publish_raw = MagicMock(return_value=True)
  46. client._raw_handlers: list = []
  47. def _register(handler):
  48. client._raw_handlers.append(handler)
  49. def _unregister(handler):
  50. if handler in client._raw_handlers:
  51. client._raw_handlers.remove(handler)
  52. client.register_raw_message_handler.side_effect = _register
  53. client.unregister_raw_message_handler.side_effect = _unregister
  54. # No-op for _request_version / request_status_update so the post-bind nudge doesn't crash.
  55. client._request_version = MagicMock()
  56. client.request_status_update = MagicMock()
  57. return client
  58. def _make_printer_manager(client) -> MagicMock:
  59. pm = MagicMock()
  60. pm.get_client = MagicMock(return_value=client)
  61. return pm
  62. def _make_bridge(server: SimpleMQTTServer, target: MagicMock | None = None) -> MQTTBridge:
  63. target = target if target is not None else _make_paho_client()
  64. pm = _make_printer_manager(target)
  65. return MQTTBridge(
  66. vp_id=1,
  67. vp_name="vp1",
  68. vp_serial=VP_SERIAL,
  69. target_printer_id=42,
  70. mqtt_server=server,
  71. printer_manager=pm,
  72. )
  73. # ---------------------------------------------------------------------------
  74. # Lifecycle
  75. # ---------------------------------------------------------------------------
  76. class TestBridgeLifecycle:
  77. @pytest.mark.asyncio
  78. async def test_start_registers_handler_on_target_client(self):
  79. target = _make_paho_client()
  80. bridge = _make_bridge(_make_server(), target)
  81. await bridge.start()
  82. assert len(target._raw_handlers) == 1
  83. assert bridge.is_active is True
  84. await bridge.stop()
  85. assert len(target._raw_handlers) == 0
  86. @pytest.mark.asyncio
  87. async def test_start_with_no_target_client_does_not_crash(self):
  88. pm = MagicMock()
  89. pm.get_client = MagicMock(return_value=None)
  90. bridge = MQTTBridge(
  91. vp_id=1,
  92. vp_name="vp1",
  93. vp_serial=VP_SERIAL,
  94. target_printer_id=42,
  95. mqtt_server=_make_server(),
  96. printer_manager=pm,
  97. )
  98. await bridge.start()
  99. assert bridge.is_active is False
  100. await bridge.stop()
  101. @pytest.mark.asyncio
  102. async def test_resolve_rebinds_when_paho_client_replaced(self):
  103. """BambuMQTTClient is destroyed and recreated on connect_printer; bridge must rebind."""
  104. old_client = _make_paho_client(serial="REAL_OLD")
  105. new_client = _make_paho_client(serial="REAL_NEW")
  106. pm = _make_printer_manager(old_client)
  107. bridge = MQTTBridge(
  108. vp_id=1,
  109. vp_name="vp1",
  110. vp_serial=VP_SERIAL,
  111. target_printer_id=42,
  112. mqtt_server=_make_server(),
  113. printer_manager=pm,
  114. )
  115. await bridge.start()
  116. assert len(old_client._raw_handlers) == 1
  117. assert bridge._target_serial == "REAL_OLD"
  118. pm.get_client.return_value = new_client
  119. bridge._resolve_client()
  120. assert len(old_client._raw_handlers) == 0
  121. assert len(new_client._raw_handlers) == 1
  122. assert bridge._target_serial == "REAL_NEW"
  123. await bridge.stop()
  124. @pytest.mark.asyncio
  125. async def test_post_bind_nudge_requests_version_and_status(self):
  126. target = _make_paho_client()
  127. bridge = _make_bridge(_make_server(), target)
  128. await bridge.start()
  129. target._request_version.assert_called_once()
  130. target.request_status_update.assert_called_once()
  131. await bridge.stop()
  132. @pytest.mark.asyncio
  133. async def test_post_bind_nudge_skipped_when_target_not_connected(self):
  134. """#1721: the bridge can attach before the real printer's MQTT TLS
  135. handshake completes. Calling request_status_update on a disconnected
  136. client logs WARNING (bambu_mqtt.py:3224); on A1 firmware that
  137. reconnects aggressively, every bind cycle pollutes the support bundle
  138. with a benign line. The bridge must check state.connected before
  139. nudging — the next periodic pushall picks up the cache anyway.
  140. """
  141. target = _make_paho_client(connected=False)
  142. bridge = _make_bridge(_make_server(), target)
  143. await bridge.start()
  144. target._request_version.assert_not_called()
  145. target.request_status_update.assert_not_called()
  146. await bridge.stop()
  147. # ---------------------------------------------------------------------------
  148. # Caching: push_status
  149. # ---------------------------------------------------------------------------
  150. class TestPushStatusCache:
  151. """push_status snapshots feed `_send_status_report` via the cache, not a fan-out."""
  152. @pytest.mark.asyncio
  153. async def test_push_status_is_cached_not_fanned_out(self):
  154. server = _make_server()
  155. server.push_raw_to_clients = AsyncMock()
  156. bridge = _make_bridge(server)
  157. await bridge.start()
  158. payload = json.dumps({"print": {"command": "push_status", "ams": {"ams": []}, "gcode_state": "IDLE"}}).encode()
  159. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", payload)
  160. await asyncio.sleep(0.01)
  161. server.push_raw_to_clients.assert_not_awaited()
  162. cached = bridge.get_latest_print_state()
  163. assert cached is not None
  164. assert cached["command"] == "push_status"
  165. assert cached["gcode_state"] == "IDLE"
  166. await bridge.stop()
  167. @pytest.mark.asyncio
  168. async def test_serial_rewritten_in_cached_push(self):
  169. server = _make_server()
  170. bridge = _make_bridge(server)
  171. await bridge.start()
  172. payload = json.dumps(
  173. {
  174. "print": {
  175. "command": "push_status",
  176. "upgrade_state": {"sn": H2D_SERIAL, "status": "IDLE"},
  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["upgrade_state"]["sn"] == VP_SERIAL
  184. await bridge.stop()
  185. @pytest.mark.asyncio
  186. async def test_net_info_ip_rewritten_to_vp_ip(self):
  187. """BambuStudio reads `net.info[].ip` (LE uint32) for the FTP destination —
  188. must be rewritten to the VP's bind IP or the slicer bypasses the VP."""
  189. server = _make_server(bind_address=VP_IP)
  190. bridge = _make_bridge(server)
  191. await bridge.start()
  192. h2d_le = _ip_to_uint32_le(H2D_IP)
  193. vp_le = _ip_to_uint32_le(VP_IP)
  194. payload = json.dumps(
  195. {
  196. "print": {
  197. "command": "push_status",
  198. "net": {"info": [{"ip": h2d_le, "mask": 0xFFFFFF}, {"ip": 0, "mask": 0}]},
  199. }
  200. }
  201. ).encode()
  202. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", payload)
  203. await asyncio.sleep(0.01)
  204. cached = bridge.get_latest_print_state()
  205. assert cached["net"]["info"][0]["ip"] == vp_le
  206. assert cached["net"]["info"][1]["ip"] == 0 # untouched
  207. await bridge.stop()
  208. @pytest.mark.asyncio
  209. async def test_net_info_ip_rewritten_for_unknown_secondary_interface(self):
  210. """Regression for #1429: real printers (X1C / H2D Pro) report multiple
  211. active interfaces (WiFi + Ethernet) — only ONE matches the IP Bambuddy
  212. tracks. The rewrite must catch every non-zero entry, not just the one
  213. whose IP equals `_target_ip_uint32_le`, or the slicer's FTP fallback
  214. path leaks straight to the real printer."""
  215. server = _make_server(bind_address=VP_IP)
  216. bridge = _make_bridge(server)
  217. await bridge.start()
  218. h2d_le = _ip_to_uint32_le(H2D_IP)
  219. # A second IP Bambuddy never saw (e.g. printer's ethernet interface
  220. # while Bambuddy talks over wifi).
  221. other_le = _ip_to_uint32_le("192.168.99.42")
  222. vp_le = _ip_to_uint32_le(VP_IP)
  223. payload = json.dumps(
  224. {
  225. "print": {
  226. "command": "push_status",
  227. "net": {
  228. "info": [
  229. {"ip": h2d_le, "mask": 0xFFFFFF},
  230. {"ip": other_le, "mask": 0xFFFFFF},
  231. {"ip": 0, "mask": 0},
  232. ]
  233. },
  234. }
  235. }
  236. ).encode()
  237. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", payload)
  238. await asyncio.sleep(0.01)
  239. cached = bridge.get_latest_print_state()
  240. assert cached["net"]["info"][0]["ip"] == vp_le
  241. assert cached["net"]["info"][1]["ip"] == vp_le # secondary interface also rewritten
  242. assert cached["net"]["info"][2]["ip"] == 0 # placeholder untouched
  243. await bridge.stop()
  244. @pytest.mark.asyncio
  245. async def test_late_arriving_printer_ip_rewrites_existing_cache(self):
  246. """Regression for #1429: if the printer's `ip_address` is empty at
  247. first bind (DB row stale, or the client object exists before the
  248. first SSDP refresh fills it in), the rewrite stays disabled and the
  249. first cached push poisons the cache with the real-printer IP.
  250. Once `ip_address` becomes valid, the next refresh tick must (a) arm
  251. the encoding and (b) sweep the cached `net.info[].ip` so the slicer
  252. sees the rewritten value on its next pull. Without the sweep the
  253. sticky-key preservation keeps the poisoned value alive across
  254. every subsequent incremental push."""
  255. server = _make_server(bind_address=VP_IP)
  256. # Bind to a client whose ip_address is empty at start — simulates the
  257. # late-arrival path.
  258. target = _make_paho_client(ip="")
  259. bridge = _make_bridge(server, target)
  260. await bridge.start()
  261. assert bridge._target_ip_uint32_le is None # not yet armed
  262. h2d_le = _ip_to_uint32_le(H2D_IP)
  263. vp_le = _ip_to_uint32_le(VP_IP)
  264. payload = json.dumps(
  265. {
  266. "print": {
  267. "command": "push_status",
  268. "net": {"info": [{"ip": h2d_le, "mask": 0xFFFFFF}]},
  269. }
  270. }
  271. ).encode()
  272. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", payload)
  273. await asyncio.sleep(0.01)
  274. # First push landed before encoding was armed → cache holds real IP.
  275. cached = bridge.get_latest_print_state()
  276. assert cached["net"]["info"][0]["ip"] == h2d_le
  277. # Printer's IP becomes known. Next refresh tick must self-heal.
  278. target.ip_address = H2D_IP
  279. bridge._resolve_client()
  280. cached = bridge.get_latest_print_state()
  281. assert cached["net"]["info"][0]["ip"] == vp_le, (
  282. "cache must be swept once encoding becomes valid; sticky-key "
  283. "preservation would otherwise keep the poisoned IP forever"
  284. )
  285. assert bridge._target_ip_uint32_le == h2d_le
  286. await bridge.stop()
  287. @pytest.mark.asyncio
  288. async def test_request_topic_message_is_ignored(self):
  289. server = _make_server()
  290. bridge = _make_bridge(server)
  291. await bridge.start()
  292. payload = json.dumps({"print": {"command": "push_status"}}).encode()
  293. bridge._on_printer_raw(f"device/{H2D_SERIAL}/request", payload)
  294. await asyncio.sleep(0.01)
  295. assert bridge.get_latest_print_state() is None
  296. await bridge.stop()
  297. @pytest.mark.asyncio
  298. async def test_incremental_push_preserves_ams_from_previous_cache(self):
  299. """Regression for #1371: Bambu firmware sends FULL push_status on
  300. pushall (with AMS/vt_tray/net/etc.) but typically OMITS those fields
  301. from 1 Hz incremental push_status updates. Without preserving the
  302. sticky keys across pushes, the cache forgets AMS info after the first
  303. incremental update, and BambuStudio (which reads the cache via the
  304. VP's 1 Hz status push) sees no AMS info until the user power-cycles
  305. the printer (forcing a fresh pushall).
  306. """
  307. server = _make_server()
  308. bridge = _make_bridge(server)
  309. await bridge.start()
  310. # 1. Initial pushall response with full state, AMS included.
  311. full_push = json.dumps(
  312. {
  313. "print": {
  314. "command": "push_status",
  315. "gcode_state": "IDLE",
  316. "wifi_signal": "-50dBm",
  317. "ams": {
  318. "ams": [
  319. {
  320. "id": "0",
  321. "tray": [
  322. {"id": "0", "tray_type": "PLA", "tray_color": "FF0000FF"},
  323. {"id": "1", "tray_type": "PETG", "tray_color": "00FF00FF"},
  324. ],
  325. }
  326. ],
  327. "tray_exist_bits": "3",
  328. },
  329. "vt_tray": {"id": "254", "tray_type": ""},
  330. "lights_report": [{"node": "chamber_light", "mode": "on"}],
  331. }
  332. }
  333. ).encode()
  334. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", full_push)
  335. await asyncio.sleep(0.01)
  336. cached = bridge.get_latest_print_state()
  337. assert cached["ams"]["ams"][0]["tray"][0]["tray_type"] == "PLA"
  338. assert cached["vt_tray"]["id"] == "254"
  339. assert cached["lights_report"][0]["mode"] == "on"
  340. # 2. Incremental push with only temp/wifi changes — NO ams field.
  341. # This is what the printer sends every ~1 s between full pushalls.
  342. incremental_push = json.dumps(
  343. {
  344. "print": {
  345. "command": "push_status",
  346. "wifi_signal": "-55dBm",
  347. "chamber_temper": 26.0,
  348. }
  349. }
  350. ).encode()
  351. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", incremental_push)
  352. await asyncio.sleep(0.01)
  353. cached = bridge.get_latest_print_state()
  354. # New fields take effect.
  355. assert cached["wifi_signal"] == "-55dBm"
  356. assert cached["chamber_temper"] == 26.0
  357. # Sticky fields preserved from the previous cache (the #1371 fix).
  358. assert "ams" in cached, "AMS field must be preserved across incremental pushes (#1371)"
  359. assert cached["ams"]["ams"][0]["tray"][0]["tray_type"] == "PLA"
  360. assert cached["ams"]["tray_exist_bits"] == "3"
  361. assert cached["vt_tray"]["id"] == "254"
  362. assert cached["lights_report"][0]["mode"] == "on"
  363. await bridge.stop()
  364. @pytest.mark.asyncio
  365. async def test_incremental_push_preserves_non_allowlisted_capability_fields(self):
  366. """Regression for #1622: BambuStudio gates Device-tab UIs (manage
  367. calibration, AMS-slot filament dropdown, ...) on capability /
  368. lifecycle fields (cali_version, print_type, mc_print_stage,
  369. device, ...) it reads off the cached push_status. Before the fix
  370. these fields were not in the allowlist and drained out of the
  371. bridge cache on the first 1 Hz incremental tick, so the slicer's
  372. Device tab would grey out the gated UIs once the cache thinned.
  373. After the fix the cache accumulates everything the printer has
  374. ever sent, dropped only when explicitly overwritten.
  375. """
  376. server = _make_server()
  377. bridge = _make_bridge(server)
  378. await bridge.start()
  379. full_push = json.dumps(
  380. {
  381. "print": {
  382. "command": "push_status",
  383. "cali_version": 2,
  384. "print_type": "idle",
  385. "gcode_state": "IDLE",
  386. "mc_print_stage": "0",
  387. "mc_stage": 0,
  388. "device": {"ext_tool": {"info": []}},
  389. "cfg": "",
  390. "home_flag": 256,
  391. "wifi_signal": "-50dBm",
  392. }
  393. }
  394. ).encode()
  395. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", full_push)
  396. await asyncio.sleep(0.01)
  397. # Incremental push carrying only temps + wifi — none of the
  398. # capability/lifecycle fields above are mentioned.
  399. incremental_push = json.dumps(
  400. {
  401. "print": {
  402. "command": "push_status",
  403. "wifi_signal": "-55dBm",
  404. "nozzle_temper": 24.5,
  405. }
  406. }
  407. ).encode()
  408. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", incremental_push)
  409. await asyncio.sleep(0.01)
  410. cached = bridge.get_latest_print_state()
  411. # Incremental values applied.
  412. assert cached["wifi_signal"] == "-55dBm"
  413. assert cached["nozzle_temper"] == 24.5
  414. # Capability / lifecycle fields preserved from the prior pushall
  415. # — the symptoms in #1622 (Device-tab UIs disabled) trace to these
  416. # exact keys missing.
  417. assert cached["cali_version"] == 2
  418. assert cached["print_type"] == "idle"
  419. assert cached["gcode_state"] == "IDLE"
  420. assert cached["mc_print_stage"] == "0"
  421. assert cached["mc_stage"] == 0
  422. assert cached["device"] == {"ext_tool": {"info": []}}
  423. assert cached["cfg"] == ""
  424. assert cached["home_flag"] == 256
  425. await bridge.stop()
  426. @pytest.mark.asyncio
  427. async def test_partial_vt_tray_update_overlays_onto_cached_full_dict(self):
  428. """Regression for #1622 round 5 (reported by @shaddowlink): right after
  429. the slicer picks a filament for the external spool (vt_tray, ams_id=255),
  430. Bambu firmware pushes a partial vt_tray carrying just the changed
  431. fields — typically ``{tray_info_idx, tray_color}`` — and omits the
  432. ~18 other keys (tray_type, state, k, n, cali_idx, nozzle_temp_min/max,
  433. tray_uuid, xcam_info, ...) the slicer needs to render the slot.
  434. Before this fix the per-field accumulate replaced the cached vt_tray
  435. wholesale (it only carried over prev keys NOT present in new), so the
  436. next 1 Hz cached-as-base push handed the slicer a stripped vt_tray and
  437. BambuStudio rendered the external slot as "invalid" until a reload
  438. triggered a fresh pushall. AMS slots didn't suffer because
  439. `_merge_ams_dict` already deep-merged them. The fix overlays incoming
  440. keys onto the previous dict for every top-level dict-shaped field
  441. (excluding ams, which keeps its own deep merge).
  442. """
  443. server = _make_server()
  444. bridge = _make_bridge(server)
  445. await bridge.start()
  446. # 1. Pushall response with the full ~20-field vt_tray dict a real
  447. # P1S sends to bootstrap the slot.
  448. full_push = json.dumps(
  449. {
  450. "print": {
  451. "command": "push_status",
  452. "vt_tray": {
  453. "id": "254",
  454. "tray_info_idx": "Pea5f68f",
  455. "tray_type": "PLA",
  456. "tray_sub_brands": "",
  457. "tray_color": "F72323FF",
  458. "tray_weight": "0",
  459. "tray_diameter": "0.00",
  460. "tray_temp": "0",
  461. "tray_time": "0",
  462. "bed_temp_type": "0",
  463. "bed_temp": "0",
  464. "nozzle_temp_max": "240",
  465. "nozzle_temp_min": "190",
  466. "xcam_info": "000000000000000000000000",
  467. "tray_uuid": "00000000000000000000000000000000",
  468. "ctype": 0,
  469. "remain": -1,
  470. "k": 0.01999999955296,
  471. "n": 1,
  472. "cali_idx": -1,
  473. "state": 3,
  474. },
  475. }
  476. }
  477. ).encode()
  478. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", full_push)
  479. await asyncio.sleep(0.01)
  480. # 2. Incremental push carrying just the two fields the slicer's pick
  481. # changed — exactly the shape the P1S firmware sends after an
  482. # ams_filament_setting ack. This is what shaddowlink's wire dump
  483. # captured for the failing case.
  484. incremental_push = json.dumps(
  485. {
  486. "print": {
  487. "command": "push_status",
  488. "vt_tray": {
  489. "tray_info_idx": "Pea5f68f",
  490. "tray_color": "76D9F4FF",
  491. },
  492. }
  493. }
  494. ).encode()
  495. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", incremental_push)
  496. await asyncio.sleep(0.01)
  497. cached = bridge.get_latest_print_state()
  498. vt = cached["vt_tray"]
  499. # Incoming fields applied.
  500. assert vt["tray_info_idx"] == "Pea5f68f"
  501. assert vt["tray_color"] == "76D9F4FF"
  502. # All other fields preserved from the prior pushall — without these
  503. # the slicer rendered the slot as invalid.
  504. assert vt["tray_type"] == "PLA"
  505. assert vt["state"] == 3
  506. assert vt["remain"] == -1
  507. assert vt["k"] == 0.01999999955296
  508. assert vt["n"] == 1
  509. assert vt["cali_idx"] == -1
  510. assert vt["nozzle_temp_min"] == "190"
  511. assert vt["nozzle_temp_max"] == "240"
  512. assert vt["tray_uuid"] == "00000000000000000000000000000000"
  513. assert vt["id"] == "254"
  514. await bridge.stop()
  515. @pytest.mark.asyncio
  516. async def test_partial_ams_status_update_preserves_unit_list(self):
  517. """#1387: Bambu firmware also sends `ams` updates where the key is
  518. present but the inner `ams` array is missing — e.g. just
  519. ``{ams_status: 1}`` or a humidity change. Before the deep-merge fix
  520. the bridge would overwrite the cached AMS with this stripped blob,
  521. the slicer would read it on the next 1 Hz push, and BambuStudio
  522. would drop the unit list and fall back to its "no AMS" render
  523. (only the external spool visible — the reporter's exact symptom).
  524. Now the partial update only mutates the fields it carries; the
  525. cached unit list survives.
  526. """
  527. server = _make_server()
  528. bridge = _make_bridge(server)
  529. await bridge.start()
  530. # 1. Pushall with full AMS state.
  531. bridge._on_printer_raw(
  532. f"device/{H2D_SERIAL}/report",
  533. json.dumps(
  534. {
  535. "print": {
  536. "command": "push_status",
  537. "ams": {
  538. "ams": [
  539. {
  540. "id": "0",
  541. "humidity": "1",
  542. "tray": [{"id": "0", "tray_type": "PLA", "tray_color": "FF0000FF"}],
  543. }
  544. ],
  545. "tray_exist_bits": "1",
  546. "ams_status": "0",
  547. },
  548. }
  549. }
  550. ).encode(),
  551. )
  552. await asyncio.sleep(0.01)
  553. # 2. Partial AMS update — only `ams_status` and `humidity` changed.
  554. # No `ams.ams` array, so prev's unit list must be preserved.
  555. bridge._on_printer_raw(
  556. f"device/{H2D_SERIAL}/report",
  557. json.dumps(
  558. {
  559. "print": {
  560. "command": "push_status",
  561. "ams": {"ams_status": "1", "humidity": "2"},
  562. }
  563. }
  564. ).encode(),
  565. )
  566. await asyncio.sleep(0.01)
  567. cached = bridge.get_latest_print_state()
  568. # Scalar fields take the new values.
  569. assert cached["ams"]["ams_status"] == "1"
  570. assert cached["ams"]["humidity"] == "2"
  571. # Unit + tray data preserved from the pushall.
  572. assert cached["ams"]["tray_exist_bits"] == "1"
  573. assert len(cached["ams"]["ams"]) == 1
  574. assert cached["ams"]["ams"][0]["tray"][0]["tray_type"] == "PLA"
  575. assert cached["ams"]["ams"][0]["tray"][0]["tray_color"] == "FF0000FF"
  576. await bridge.stop()
  577. @pytest.mark.asyncio
  578. async def test_partial_ams_unit_update_preserves_other_units(self):
  579. """#1387: when multiple AMS units are configured (e.g. H2D with two
  580. AMS), an incremental push during a print typically only carries the
  581. unit / tray that changed state. Naive replacement of `ams.ams` wipes
  582. the other unit. The bridge merges unit-by-unit by id, preserving
  583. units the incremental doesn't mention.
  584. """
  585. server = _make_server()
  586. bridge = _make_bridge(server)
  587. await bridge.start()
  588. # 1. Pushall with two AMS units configured.
  589. bridge._on_printer_raw(
  590. f"device/{H2D_SERIAL}/report",
  591. json.dumps(
  592. {
  593. "print": {
  594. "command": "push_status",
  595. "ams": {
  596. "ams": [
  597. {"id": "0", "tray": [{"id": "0", "tray_type": "PLA"}]},
  598. {"id": "1", "tray": [{"id": "0", "tray_type": "PETG"}]},
  599. ],
  600. # bit 0 (AMS 0 slot 0) + bit 4 (AMS 1 slot 0) = 0x11.
  601. # `_on_printer_raw` now applies the #1726 bitmask
  602. # cleanup to the cached state, so the test fixture
  603. # must declare both loaded slots — same shape the
  604. # real printer sends.
  605. "tray_exist_bits": "11",
  606. },
  607. }
  608. }
  609. ).encode(),
  610. )
  611. await asyncio.sleep(0.01)
  612. # 2. Tray-targeted incremental: unit 0 / tray 0 state changed.
  613. # Unit 1 is not in the update — must survive.
  614. bridge._on_printer_raw(
  615. f"device/{H2D_SERIAL}/report",
  616. json.dumps(
  617. {
  618. "print": {
  619. "command": "push_status",
  620. "ams": {"ams": [{"id": "0", "tray": [{"id": "0", "state": "11"}]}]},
  621. }
  622. }
  623. ).encode(),
  624. )
  625. await asyncio.sleep(0.01)
  626. cached = bridge.get_latest_print_state()
  627. units = {u["id"]: u for u in cached["ams"]["ams"]}
  628. # Unit 0 keeps its tray_type from the pushall + picks up the new state.
  629. assert units["0"]["tray"][0]["tray_type"] == "PLA"
  630. assert units["0"]["tray"][0]["state"] == "11"
  631. # Unit 1 survives the incremental.
  632. assert "1" in units
  633. assert units["1"]["tray"][0]["tray_type"] == "PETG"
  634. await bridge.stop()
  635. @pytest.mark.asyncio
  636. async def test_tray_exist_bits_clears_empty_slots_in_slicer_cache(self):
  637. """#1726 (reported by @needo37): the bridge cache forwards the real
  638. printer's raw AMS payload to the slicer. Without the empty-slot
  639. cleanup that bambu_mqtt.py applies to Bambuddy's internal state, the
  640. cached units carried stale `tray_type` / `tray_color` /
  641. `tray_info_idx` for slots whose `tray_exist_bits` bit was 0 — and
  642. BambuStudio's Sync rendered those empty slots as phantom loaded
  643. filaments. After the fix the bridge runs the same shared
  644. ``apply_tray_exist_bits`` helper before storing the cache.
  645. """
  646. server = _make_server()
  647. bridge = _make_bridge(server)
  648. await bridge.start()
  649. # Pushall: AMS 0 has slots 0/1/2/3; only slots 1, 2, 3 are loaded.
  650. # Slot 0 carries stale data (RFID/color/material from a previously
  651. # loaded spool). `tray_exist_bits` = 0xe = 0b1110 → bit 0 unset.
  652. bridge._on_printer_raw(
  653. f"device/{H2D_SERIAL}/report",
  654. json.dumps(
  655. {
  656. "print": {
  657. "command": "push_status",
  658. "ams": {
  659. "ams": [
  660. {
  661. "id": "0",
  662. "tray": [
  663. {
  664. "id": "0",
  665. "tray_type": "PLA",
  666. "tray_color": "FF0000FF",
  667. "tray_info_idx": "GFL00",
  668. "tag_uid": "1234567890abcdef",
  669. "tray_uuid": "abcdef1234567890abcdef1234567890",
  670. "remain": 75,
  671. "state": "11",
  672. },
  673. {"id": "1", "tray_type": "PETG", "tray_color": "00FF00FF"},
  674. {"id": "2", "tray_type": "ABS", "tray_color": "0000FFFF"},
  675. {"id": "3", "tray_type": "TPU", "tray_color": "FFFF00FF"},
  676. ],
  677. }
  678. ],
  679. "tray_exist_bits": "e",
  680. },
  681. }
  682. }
  683. ).encode(),
  684. )
  685. await asyncio.sleep(0.01)
  686. cached = bridge.get_latest_print_state()
  687. slot0 = cached["ams"]["ams"][0]["tray"][0]
  688. # Empty slot: stale per-tray fields wiped, state promoted to 9.
  689. assert slot0["state"] == 9, "empty slot must be promoted to state=9"
  690. assert slot0["tray_type"] == ""
  691. assert slot0["tray_color"] == ""
  692. assert slot0["tray_info_idx"] == ""
  693. assert slot0["tag_uid"] == "0000000000000000"
  694. assert slot0["tray_uuid"] == "00000000000000000000000000000000"
  695. assert slot0["remain"] == 0
  696. # Loaded slots preserved.
  697. assert cached["ams"]["ams"][0]["tray"][1]["tray_type"] == "PETG"
  698. assert cached["ams"]["ams"][0]["tray"][2]["tray_type"] == "ABS"
  699. assert cached["ams"]["ams"][0]["tray"][3]["tray_type"] == "TPU"
  700. await bridge.stop()
  701. @pytest.mark.asyncio
  702. async def test_a2l_ams_lite_slots_survive_in_slicer_cache(self):
  703. """#2697 (reported by @qoatzelcoat): every A2L slot rendered as "?" in
  704. BambuStudio through the VP, while Bambuddy's own AMS card was correct.
  705. The A2L reports its AMS Lite as physical unit id 16 but packs the
  706. presence bits at base 24. Bambuddy's internal path normalises 16 -> 6
  707. before the cleanup runs, so it read the right bits; the bridge parses
  708. the raw printer payload itself and still held 16, so the cleanup read
  709. bits 64-67 — never set — and wiped all four slots in the cache the
  710. slicer reads. A slicer-side filament pick reverted on the next 1 Hz
  711. push for the same reason.
  712. The cached units must keep the physical id 16: BambuStudio addresses
  713. the Lite as 16 (it sends `ams_get_rfid {ams_id: 16}` through the VP).
  714. """
  715. server = _make_server()
  716. bridge = _make_bridge(server)
  717. await bridge.start()
  718. # Reporter's capture: tray_exist_bits 0x7000000 = bits 24/25/26 →
  719. # slots 0, 1, 2 loaded, slot 3 empty.
  720. bridge._on_printer_raw(
  721. f"device/{H2D_SERIAL}/report",
  722. json.dumps(
  723. {
  724. "print": {
  725. "command": "push_status",
  726. "ams": {
  727. "ams": [
  728. {
  729. "id": "16",
  730. "tray": [
  731. {
  732. "id": "0",
  733. "state": 3,
  734. "tray_type": "PLA",
  735. "tray_sub_brands": "PLA Basic",
  736. "tray_color": "C12E1FFF",
  737. "tray_info_idx": "GFA00",
  738. "remain": 100,
  739. },
  740. {"id": "1", "state": 3, "tray_type": "PETG", "tray_info_idx": "GFG00"},
  741. {"id": "2", "state": 3, "tray_type": "ABS", "tray_info_idx": "GFB00"},
  742. {"id": "3", "state": 3, "tray_type": "TPU", "tray_info_idx": "GFU00"},
  743. ],
  744. }
  745. ],
  746. "tray_exist_bits": "7000000",
  747. },
  748. }
  749. }
  750. ).encode(),
  751. )
  752. await asyncio.sleep(0.01)
  753. cached = bridge.get_latest_print_state()
  754. unit = cached["ams"]["ams"][0]
  755. # The slicer-facing cache keeps the PHYSICAL id — BambuStudio speaks 16.
  756. assert unit["id"] == "16"
  757. trays = unit["tray"]
  758. assert trays[0]["tray_type"] == "PLA", "loaded slot wrongly cleared (bit base 64 regression)"
  759. assert trays[1]["tray_type"] == "PETG"
  760. assert trays[2]["tray_type"] == "ABS"
  761. assert trays[0]["tray_info_idx"] == "GFA00"
  762. # Slot 3 is genuinely empty and still gets the normal cleanup.
  763. assert trays[3]["state"] == 9
  764. assert trays[3]["tray_type"] == ""
  765. await bridge.stop()
  766. @pytest.mark.asyncio
  767. async def test_tray_exist_bits_shutdown_guard_preserves_cache(self):
  768. """#765 shutdown guard mirrored at the bridge: when the printer
  769. powers off it sends all-zero `tray_exist_bits` paired with
  770. `power_on_flag=False`. Wiping the cache on that pattern would
  771. propagate phantom empties to every slicer reconnect until the
  772. printer powers back on and pushes a real state. Skip cleanup
  773. on the shutdown-shaped payload."""
  774. server = _make_server()
  775. bridge = _make_bridge(server)
  776. await bridge.start()
  777. # 1. Normal pushall — all four slots loaded.
  778. bridge._on_printer_raw(
  779. f"device/{H2D_SERIAL}/report",
  780. json.dumps(
  781. {
  782. "print": {
  783. "command": "push_status",
  784. "ams": {
  785. "ams": [
  786. {
  787. "id": "0",
  788. "tray": [
  789. {"id": str(i), "tray_type": "PLA", "tray_color": f"{i:02x}{i:02x}{i:02x}FF"}
  790. for i in range(4)
  791. ],
  792. }
  793. ],
  794. "tray_exist_bits": "f",
  795. "power_on_flag": True,
  796. },
  797. }
  798. }
  799. ).encode(),
  800. )
  801. await asyncio.sleep(0.01)
  802. # 2. Shutdown-shaped push: tray_exist_bits=0 + power_on_flag=False.
  803. bridge._on_printer_raw(
  804. f"device/{H2D_SERIAL}/report",
  805. json.dumps(
  806. {
  807. "print": {
  808. "command": "push_status",
  809. "ams": {
  810. "tray_exist_bits": "0",
  811. "power_on_flag": False,
  812. },
  813. }
  814. }
  815. ).encode(),
  816. )
  817. await asyncio.sleep(0.01)
  818. cached = bridge.get_latest_print_state()
  819. for i in range(4):
  820. assert cached["ams"]["ams"][0]["tray"][i]["tray_type"] == "PLA", f"slot {i} must survive the shutdown push"
  821. await bridge.stop()
  822. @pytest.mark.asyncio
  823. async def test_tray_exist_bits_clears_empty_ams_ht_unit(self):
  824. """AMS-HT (id 128-135) presence rides bit 16+(ams_id-128), so the bridge
  825. cache clears an empty HT slot just like the internal AMS card — keeping
  826. the slicer-facing view in sync (#1726, #2670). Loaded (bit 16 set) is
  827. preserved; empty (bit 16 clear) is wiped.
  828. """
  829. server = _make_server()
  830. bridge = _make_bridge(server)
  831. await bridge.start()
  832. def _push(tray_exist_bits: str) -> dict:
  833. bridge._on_printer_raw(
  834. f"device/{H2D_SERIAL}/report",
  835. json.dumps(
  836. {
  837. "print": {
  838. "command": "push_status",
  839. "ams": {
  840. "ams": [
  841. {
  842. "id": "128",
  843. "tray": [
  844. {"id": "0", "tray_type": "PLA", "tray_color": "FF0000FF"},
  845. ],
  846. }
  847. ],
  848. "tray_exist_bits": tray_exist_bits,
  849. "power_on_flag": True,
  850. },
  851. }
  852. }
  853. ).encode(),
  854. )
  855. # Loaded: bit 16 set → HT slot preserved.
  856. _push("10000")
  857. await asyncio.sleep(0.01)
  858. assert bridge.get_latest_print_state()["ams"]["ams"][0]["tray"][0]["tray_type"] == "PLA"
  859. # Empty: bit 16 clear → HT slot wiped so the slicer sees no phantom spool.
  860. _push("0")
  861. await asyncio.sleep(0.01)
  862. assert bridge.get_latest_print_state()["ams"]["ams"][0]["tray"][0]["tray_type"] == ""
  863. await bridge.stop()
  864. @pytest.mark.asyncio
  865. async def test_partial_ams_tray_update_preserves_other_trays(self):
  866. """Same shape as the unit-level test but at the tray level. AMS
  867. unit 0 has four trays; the incremental only mentions tray 0.
  868. Trays 1-3 must survive intact."""
  869. server = _make_server()
  870. bridge = _make_bridge(server)
  871. await bridge.start()
  872. bridge._on_printer_raw(
  873. f"device/{H2D_SERIAL}/report",
  874. json.dumps(
  875. {
  876. "print": {
  877. "command": "push_status",
  878. "ams": {
  879. "ams": [
  880. {
  881. "id": "0",
  882. "tray": [
  883. {"id": "0", "tray_type": "PLA", "tray_color": "FF0000FF"},
  884. {"id": "1", "tray_type": "PETG", "tray_color": "00FF00FF"},
  885. {"id": "2", "tray_type": "ABS", "tray_color": "0000FFFF"},
  886. {"id": "3", "tray_type": "TPU", "tray_color": "FFFF00FF"},
  887. ],
  888. }
  889. ],
  890. },
  891. }
  892. }
  893. ).encode(),
  894. )
  895. await asyncio.sleep(0.01)
  896. bridge._on_printer_raw(
  897. f"device/{H2D_SERIAL}/report",
  898. json.dumps(
  899. {
  900. "print": {
  901. "command": "push_status",
  902. "ams": {"ams": [{"id": "0", "tray": [{"id": "0", "state": "11"}]}]},
  903. }
  904. }
  905. ).encode(),
  906. )
  907. await asyncio.sleep(0.01)
  908. cached = bridge.get_latest_print_state()
  909. trays = {t["id"]: t for t in cached["ams"]["ams"][0]["tray"]}
  910. assert trays["0"]["tray_type"] == "PLA"
  911. assert trays["0"]["state"] == "11"
  912. # Trays not mentioned in the incremental survive intact.
  913. assert trays["1"]["tray_type"] == "PETG"
  914. assert trays["2"]["tray_type"] == "ABS"
  915. assert trays["3"]["tray_type"] == "TPU"
  916. await bridge.stop()
  917. @pytest.mark.asyncio
  918. async def test_incoming_ams_update_replaces_cached_ams(self):
  919. """Counterpart to the #1371 fix: preservation only kicks in when the
  920. incoming push OMITS a sticky key. When the printer DOES send a fresh
  921. `ams` value (e.g. on a pushall, or when AMS state genuinely changes),
  922. that value must take effect — the preservation must not shadow real
  923. updates.
  924. """
  925. server = _make_server()
  926. bridge = _make_bridge(server)
  927. await bridge.start()
  928. # 1. Initial state: PLA in tray 0.
  929. bridge._on_printer_raw(
  930. f"device/{H2D_SERIAL}/report",
  931. json.dumps(
  932. {
  933. "print": {
  934. "command": "push_status",
  935. "ams": {"ams": [{"id": "0", "tray": [{"id": "0", "tray_type": "PLA"}]}]},
  936. }
  937. }
  938. ).encode(),
  939. )
  940. await asyncio.sleep(0.01)
  941. # 2. Fresh push with PETG — must replace, not get shadowed by the old PLA.
  942. bridge._on_printer_raw(
  943. f"device/{H2D_SERIAL}/report",
  944. json.dumps(
  945. {
  946. "print": {
  947. "command": "push_status",
  948. "ams": {"ams": [{"id": "0", "tray": [{"id": "0", "tray_type": "PETG"}]}]},
  949. }
  950. }
  951. ).encode(),
  952. )
  953. await asyncio.sleep(0.01)
  954. cached = bridge.get_latest_print_state()
  955. assert cached["ams"]["ams"][0]["tray"][0]["tray_type"] == "PETG"
  956. await bridge.stop()
  957. # ---------------------------------------------------------------------------
  958. # Caching: get_version response
  959. # ---------------------------------------------------------------------------
  960. class TestVersionCache:
  961. @pytest.mark.asyncio
  962. async def test_get_version_response_caches_modules(self):
  963. server = _make_server()
  964. bridge = _make_bridge(server)
  965. await bridge.start()
  966. payload = json.dumps(
  967. {
  968. "info": {
  969. "command": "get_version",
  970. "module": [
  971. {"name": "ota", "sn": H2D_SERIAL, "sw_ver": "01.03.00.00"},
  972. {"name": "n3f/0", "sn": "AMS_HW_1", "sw_ver": "04.00.21.87"},
  973. ],
  974. }
  975. }
  976. ).encode()
  977. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", payload)
  978. await asyncio.sleep(0.01)
  979. modules = bridge.get_latest_version_modules()
  980. assert modules is not None
  981. assert len(modules) == 2
  982. # Device-level sn rewritten; AMS-hardware sn left alone.
  983. assert modules[0]["sn"] == VP_SERIAL
  984. assert modules[1]["sn"] == "AMS_HW_1"
  985. await bridge.stop()
  986. # ---------------------------------------------------------------------------
  987. # Selective fan-out (everything that's not push_status / get_version)
  988. # ---------------------------------------------------------------------------
  989. class TestCommandResponseFanout:
  990. @pytest.mark.asyncio
  991. async def test_extrusion_cali_get_response_is_fanned_out(self):
  992. """Slicer's extrusion_cali_get goes to the printer; the printer's response
  993. must reach the slicer or BambuStudio's pre-flight blocks Send."""
  994. server = _make_server()
  995. server.push_raw_to_clients = AsyncMock()
  996. bridge = _make_bridge(server)
  997. await bridge.start()
  998. body = json.dumps({"print": {"command": "extrusion_cali_get", "filaments": []}}).encode()
  999. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", body)
  1000. await asyncio.sleep(0.01)
  1001. server.push_raw_to_clients.assert_awaited_once()
  1002. topic, _payload = server.push_raw_to_clients.await_args.args
  1003. assert topic == f"device/{VP_SERIAL}/report"
  1004. await bridge.stop()
  1005. # ---------------------------------------------------------------------------
  1006. # Forwarding: slicer → printer
  1007. # ---------------------------------------------------------------------------
  1008. class TestForwardToPrinter:
  1009. @pytest.mark.asyncio
  1010. async def test_forward_publishes_to_real_serial_request_topic(self):
  1011. target = _make_paho_client()
  1012. bridge = _make_bridge(_make_server(), target)
  1013. await bridge.start()
  1014. ok = bridge.forward_to_printer({"print": {"command": "stop"}})
  1015. assert ok is True
  1016. target.publish_raw.assert_called_once()
  1017. topic, payload = target.publish_raw.call_args.args
  1018. assert topic == f"device/{H2D_SERIAL}/request"
  1019. assert json.loads(payload) == {"print": {"command": "stop"}}
  1020. await bridge.stop()
  1021. @pytest.mark.asyncio
  1022. async def test_forward_returns_false_when_not_bound(self):
  1023. pm = MagicMock()
  1024. pm.get_client = MagicMock(return_value=None)
  1025. bridge = MQTTBridge(
  1026. vp_id=1,
  1027. vp_name="vp1",
  1028. vp_serial=VP_SERIAL,
  1029. target_printer_id=42,
  1030. mqtt_server=_make_server(),
  1031. printer_manager=pm,
  1032. )
  1033. await bridge.start()
  1034. assert bridge.forward_to_printer({"print": {"command": "stop"}}) is False
  1035. await bridge.stop()
  1036. # ---------------------------------------------------------------------------
  1037. # SimpleMQTTServer status response: cached-as-base
  1038. # ---------------------------------------------------------------------------
  1039. def _capture_published(server: SimpleMQTTServer) -> list:
  1040. """Wrap _publish_to_report to capture (topic, payload_dict)."""
  1041. published: list = []
  1042. async def _capture(writer, payload, serial="", log_event=True):
  1043. published.append((serial or server.serial, payload))
  1044. server._publish_to_report = _capture # type: ignore[assignment]
  1045. return published
  1046. class TestStatusReportCachedAsBase:
  1047. """`_send_status_report` sends near-byte-identical real data when bridge cache exists."""
  1048. def _capture_published(self, server: SimpleMQTTServer):
  1049. return _capture_published(server)
  1050. @pytest.mark.asyncio
  1051. async def test_uses_real_cache_when_bridge_active(self):
  1052. server = _make_server()
  1053. bridge = MagicMock()
  1054. bridge.get_latest_print_state.return_value = {
  1055. "command": "push_status",
  1056. "msg": 0,
  1057. "ams": {"ams": [{"id": "0"}]},
  1058. "device": {"extruder": {"info": [{"id": 0}, {"id": 1}]}},
  1059. "nozzle_diameter": "0.4",
  1060. "nozzle_type": "HH01", # real H2D value, not synthetic 'hardened_steel'
  1061. }
  1062. server.set_bridge(bridge)
  1063. published = self._capture_published(server)
  1064. await server._send_status_report(MagicMock())
  1065. assert len(published) == 1
  1066. _serial, payload = published[0]
  1067. # AMS / device / nozzle_type all from cache
  1068. assert payload["print"]["nozzle_type"] == "HH01"
  1069. assert payload["print"]["device"]["extruder"]["info"][1]["id"] == 1
  1070. # Protocol fields under our control
  1071. assert payload["print"]["command"] == "push_status"
  1072. assert payload["print"]["gcode_state"] == "IDLE"
  1073. @pytest.mark.asyncio
  1074. async def test_falls_back_to_synthetic_when_no_cache(self):
  1075. server = _make_server()
  1076. bridge = MagicMock()
  1077. bridge.get_latest_print_state.return_value = None
  1078. server.set_bridge(bridge)
  1079. published = self._capture_published(server)
  1080. await server._send_status_report(MagicMock())
  1081. assert len(published) == 1
  1082. _serial, payload = published[0]
  1083. # Synthetic baseline has stub fields like nozzle_type='hardened_steel'
  1084. # and a `storage` field that the real H2D doesn't push.
  1085. assert payload["print"]["nozzle_type"] == "hardened_steel"
  1086. assert "storage" in payload["print"]
  1087. @pytest.mark.asyncio
  1088. async def test_storage_indicators_overlaid_for_send_preflight(self):
  1089. """#1228: P1S/A1-class firmware doesn't always include the SD/storage
  1090. fields BambuStudio's "Send" pre-flight reads. Without these the
  1091. slicer rejects with 'storage needs to be inserted' before even
  1092. attempting FTP. The cached-as-base path now overlays them so the
  1093. pre-flight passes regardless of what the real printer reports.
  1094. """
  1095. server = _make_server()
  1096. bridge = MagicMock()
  1097. # Real P1S push without SD card inserted: home_flag has other bits set
  1098. # but the SD bit (0x100) is clear; sdcard is False; no storage field.
  1099. bridge.get_latest_print_state.return_value = {
  1100. "command": "push_status",
  1101. "msg": 0,
  1102. "home_flag": 0x42,
  1103. "sdcard": False,
  1104. }
  1105. server.set_bridge(bridge)
  1106. published = self._capture_published(server)
  1107. await server._send_status_report(MagicMock())
  1108. _serial, payload = published[0]
  1109. # SD bit ORed onto whatever was there — other bits preserved.
  1110. assert payload["print"]["home_flag"] & 0x100 == 0x100
  1111. assert payload["print"]["home_flag"] & 0x42 == 0x42
  1112. # Force-set so a False from the printer doesn't trip the pre-flight.
  1113. assert payload["print"]["sdcard"] is True
  1114. # storage was missing — the overlay must inject a non-empty default.
  1115. assert "storage" in payload["print"]
  1116. assert payload["print"]["storage"]["free"] > 0
  1117. assert payload["print"]["storage"]["total"] > 0
  1118. @pytest.mark.asyncio
  1119. async def test_storage_indicators_preserve_real_storage_when_present(self):
  1120. """When the real printer DOES report a storage block, pass it through
  1121. unchanged (the overlay only fills in the missing field, not overrides).
  1122. """
  1123. server = _make_server()
  1124. bridge = MagicMock()
  1125. real_storage = {"free": 12345, "total": 67890}
  1126. bridge.get_latest_print_state.return_value = {
  1127. "command": "push_status",
  1128. "msg": 0,
  1129. "home_flag": 0x100, # SD bit already set on the real printer
  1130. "sdcard": True,
  1131. "storage": real_storage,
  1132. }
  1133. server.set_bridge(bridge)
  1134. published = self._capture_published(server)
  1135. await server._send_status_report(MagicMock())
  1136. _serial, payload = published[0]
  1137. # SD bit OR is idempotent — already-set bit stays set.
  1138. assert payload["print"]["home_flag"] == 0x100
  1139. assert payload["print"]["sdcard"] is True
  1140. # Real values pass through, NOT the synthetic defaults.
  1141. assert payload["print"]["storage"] == real_storage
  1142. @pytest.mark.asyncio
  1143. async def test_overrides_protocol_fields_even_when_cache_present(self):
  1144. """Cached value's gcode_state must NOT win over our local upload-state-machine value."""
  1145. server = _make_server()
  1146. server._gcode_state = "PREPARE"
  1147. server._current_file = "foo.3mf"
  1148. bridge = MagicMock()
  1149. bridge.get_latest_print_state.return_value = {
  1150. "command": "push_status",
  1151. "gcode_state": "IDLE", # printer is idle; we are mid-FTP-upload
  1152. "gcode_file": "",
  1153. "gcode_file_prepare_percent": "0",
  1154. }
  1155. server.set_bridge(bridge)
  1156. published = self._capture_published(server)
  1157. await server._send_status_report(MagicMock())
  1158. _serial, payload = published[0]
  1159. assert payload["print"]["gcode_state"] == "PREPARE"
  1160. assert payload["print"]["gcode_file"] == "foo.3mf"
  1161. # ---------------------------------------------------------------------------
  1162. # Live print progress (#1887 / #1558)
  1163. # ---------------------------------------------------------------------------
  1164. def _printing_cache(**overrides) -> dict:
  1165. """Bridge cache for a target printer that is mid-print."""
  1166. cache = {
  1167. "command": "push_status",
  1168. "msg": 0,
  1169. "gcode_state": "RUNNING",
  1170. "gcode_file": "Metadata/plate_1.gcode",
  1171. "subtask_name": "benchy",
  1172. "mc_print_stage": "2",
  1173. "mc_percent": 47,
  1174. "mc_remaining_time": 3600,
  1175. "stg": [1, 2, 3],
  1176. "stg_cur": 14,
  1177. "layer_num": 120,
  1178. "total_layer_num": 250,
  1179. "print_error": 0,
  1180. }
  1181. cache.update(overrides)
  1182. return cache
  1183. class TestLiveProgressMirror:
  1184. """The VP mirrors the target printer's progress without ever looking busy.
  1185. Both slicers gate the Device-tab progress panel and the Send button on the
  1186. same predicate — `MachineObject::is_in_printing()`, i.e. gcode_state in
  1187. {RUNNING, PAUSE, SLICING, PREPARE}. Reporting the printer's real state
  1188. shows progress but blocks Send for as long as it prints (#1558); zeroing
  1189. everything keeps Send alive but shows nothing (#1887). FINISH is the one
  1190. state that does both: StatusPanel renders on `is_in_printing() ||
  1191. print_status == "FINISH"`, while SelectMachineDialog only blocks on
  1192. `is_in_printing()`.
  1193. """
  1194. @pytest.mark.asyncio
  1195. async def test_progress_mirrored_while_target_prints(self):
  1196. """#1887: the numbers the slicer needs come straight from the cache."""
  1197. server = _make_server()
  1198. bridge = MagicMock()
  1199. bridge.get_latest_print_state.return_value = _printing_cache()
  1200. server.set_bridge(bridge)
  1201. published = _capture_published(server)
  1202. await server._send_status_report(MagicMock())
  1203. _serial, payload = published[0]
  1204. assert payload["print"]["mc_print_stage"] == "2"
  1205. assert payload["print"]["mc_percent"] == 47
  1206. assert payload["print"]["mc_remaining_time"] == 3600
  1207. assert payload["print"]["stg"] == [1, 2, 3]
  1208. assert payload["print"]["stg_cur"] == 14
  1209. assert payload["print"]["layer_num"] == 120
  1210. assert payload["print"]["total_layer_num"] == 250
  1211. # The job the printer is really running, not the VP's last upload.
  1212. assert payload["print"]["subtask_name"] == "benchy"
  1213. @pytest.mark.asyncio
  1214. @pytest.mark.parametrize("target_state", ["RUNNING", "PAUSE"])
  1215. async def test_mirror_never_reports_a_printing_gcode_state(self, target_state):
  1216. """#1558 guard: any state in `is_in_printing()` disables the Send button.
  1217. This is the assertion that keeps the mirror honest — it may show the
  1218. printer's numbers, but it must never claim the VP itself is printing.
  1219. """
  1220. server = _make_server()
  1221. bridge = MagicMock()
  1222. bridge.get_latest_print_state.return_value = _printing_cache(gcode_state=target_state)
  1223. server.set_bridge(bridge)
  1224. published = _capture_published(server)
  1225. await server._send_status_report(MagicMock())
  1226. _serial, payload = published[0]
  1227. assert payload["print"]["gcode_state"] == "FINISH"
  1228. assert payload["print"]["gcode_state"] not in ("RUNNING", "PAUSE", "SLICING", "PREPARE")
  1229. @pytest.mark.asyncio
  1230. async def test_progress_zeroed_while_target_idle(self):
  1231. """Nothing to mirror — the VP's own upload state owns the report."""
  1232. server = _make_server()
  1233. server.set_gcode_state("FINISH", filename="foo.3mf", prepare_percent="100")
  1234. server._state_changed_at = time.monotonic() - 60 # settled long ago
  1235. bridge = MagicMock()
  1236. bridge.get_latest_print_state.return_value = _printing_cache(gcode_state="IDLE", mc_percent=0, layer_num=0)
  1237. server.set_bridge(bridge)
  1238. published = _capture_published(server)
  1239. await server._send_status_report(MagicMock())
  1240. _serial, payload = published[0]
  1241. assert payload["print"]["gcode_state"] == "FINISH" # the VP's own, not mirrored
  1242. assert payload["print"]["subtask_name"] == "foo"
  1243. assert payload["print"]["mc_print_stage"] == ""
  1244. assert payload["print"]["stg"] == []
  1245. assert payload["print"]["total_layer_num"] == 0
  1246. @pytest.mark.asyncio
  1247. async def test_progress_zeroed_while_upload_in_flight(self):
  1248. """A job being handed over outranks the mirror.
  1249. The slicer is watching its own PREPARE → FINISH cycle here; feeding it
  1250. the printer's progress mid-handshake would contradict the PREPARE it is
  1251. waiting on.
  1252. """
  1253. server = _make_server()
  1254. server.set_gcode_state("PREPARE", filename="bar.3mf", prepare_percent="0")
  1255. bridge = MagicMock()
  1256. bridge.get_latest_print_state.return_value = _printing_cache()
  1257. server.set_bridge(bridge)
  1258. published = _capture_published(server)
  1259. await server._send_status_report(MagicMock())
  1260. _serial, payload = published[0]
  1261. assert payload["print"]["gcode_state"] == "PREPARE"
  1262. assert payload["print"]["gcode_file"] == "bar.3mf"
  1263. assert payload["print"]["subtask_name"] == "bar"
  1264. assert payload["print"]["mc_percent"] == 0
  1265. assert payload["print"]["layer_num"] == 0
  1266. @pytest.mark.asyncio
  1267. async def test_upload_settle_window_keeps_the_slicers_own_filename(self):
  1268. """#1658: the send modal releases on FINISH carrying the name it uploaded.
  1269. Swapping in the printer's filename while that handshake is still in
  1270. flight wedges the slicer at "Downloading", so the mirror waits.
  1271. """
  1272. server = _make_server()
  1273. bridge = MagicMock()
  1274. bridge.get_latest_print_state.return_value = _printing_cache()
  1275. server.set_bridge(bridge)
  1276. published = _capture_published(server)
  1277. server.set_gcode_state("FINISH", filename="bar.3mf", prepare_percent="100")
  1278. await server._send_status_report(MagicMock())
  1279. _serial, payload = published[0]
  1280. assert payload["print"]["subtask_name"] == "bar"
  1281. assert payload["print"]["mc_percent"] == 0
  1282. @pytest.mark.asyncio
  1283. async def test_mirror_resumes_once_the_upload_has_settled(self):
  1284. """Same VP as above, once the slicer has had its FINISH."""
  1285. server = _make_server()
  1286. bridge = MagicMock()
  1287. bridge.get_latest_print_state.return_value = _printing_cache()
  1288. server.set_bridge(bridge)
  1289. published = _capture_published(server)
  1290. server.set_gcode_state("FINISH", filename="bar.3mf", prepare_percent="100")
  1291. server._state_changed_at = time.monotonic() - _UPLOAD_SETTLE_SECONDS - 1
  1292. await server._send_status_report(MagicMock())
  1293. _serial, payload = published[0]
  1294. assert payload["print"]["subtask_name"] == "benchy"
  1295. assert payload["print"]["mc_percent"] == 47
  1296. @pytest.mark.asyncio
  1297. async def test_print_error_never_mirrored(self):
  1298. """A fault on the printer must not raise a modal error dialog in the slicer.
  1299. The VP is not the machine that threw it — Bambuddy's own printer card
  1300. reports the fault.
  1301. """
  1302. server = _make_server()
  1303. bridge = MagicMock()
  1304. bridge.get_latest_print_state.return_value = _printing_cache(print_error=515)
  1305. server.set_bridge(bridge)
  1306. published = _capture_published(server)
  1307. await server._send_status_report(MagicMock())
  1308. _serial, payload = published[0]
  1309. assert payload["print"]["print_error"] == 0
  1310. assert payload["print"]["mc_percent"] == 47 # the rest still mirrors
  1311. # ---------------------------------------------------------------------------
  1312. # Wire format
  1313. # ---------------------------------------------------------------------------
  1314. class TestWireFormat:
  1315. """BambuStudio's Send pre-flight rejects compact JSON — must match real printer's
  1316. indented format (32K bytes for an idle H2D vs 14K compact)."""
  1317. @pytest.mark.asyncio
  1318. async def test_publish_uses_indent_4_json_format(self):
  1319. server = _make_server()
  1320. captured: list = []
  1321. async def _capture_drain():
  1322. pass
  1323. writer = MagicMock()
  1324. writer.write = lambda data: captured.append(data)
  1325. writer.drain = AsyncMock()
  1326. await server._publish_to_report(writer, {"print": {"command": "push_status", "ams": {}}})
  1327. body = b"".join(captured)
  1328. assert b'\n "print"' in body, "publish_to_report must use indent=4 JSON"
  1329. @pytest.mark.asyncio
  1330. async def test_publish_records_bridge_to_slicer_event_by_default(self, monkeypatch):
  1331. """#1622 round 3: every bridge-synthesised reply (info.get_version answer,
  1332. project_file ack, on-demand pushall response) must show up in the
  1333. cmd.jsonl trace under the ``bridge_to_slicer`` direction so a P1S↔H2D
  1334. diff captures the fingerprint the slicer reads back from us."""
  1335. server = _make_server()
  1336. writer = MagicMock()
  1337. writer.write = lambda data: None
  1338. writer.drain = AsyncMock()
  1339. recorded: list = []
  1340. monkeypatch.setattr(
  1341. "backend.app.services.virtual_printer.mqtt_server.append_event",
  1342. lambda vp_name, direction, topic, payload: recorded.append((vp_name, direction, topic, payload)),
  1343. )
  1344. payload = {"info": {"command": "get_version", "sequence_id": "0"}}
  1345. await server._publish_to_report(writer, payload)
  1346. assert len(recorded) == 1
  1347. vp_name, direction, topic, recorded_payload = recorded[0]
  1348. assert direction == "bridge_to_slicer"
  1349. assert topic.endswith("/report")
  1350. assert recorded_payload == payload
  1351. @pytest.mark.asyncio
  1352. async def test_publish_skips_event_when_log_event_false(self, monkeypatch):
  1353. """The 1Hz periodic-push path passes ``log_event=False`` so dump_wire's
  1354. snapshot stays the canonical record of cache shape and the cmd.jsonl
  1355. isn't flooded with ~60 lines/min per VP."""
  1356. server = _make_server()
  1357. writer = MagicMock()
  1358. writer.write = lambda data: None
  1359. writer.drain = AsyncMock()
  1360. recorded: list = []
  1361. monkeypatch.setattr(
  1362. "backend.app.services.virtual_printer.mqtt_server.append_event",
  1363. lambda *args, **kwargs: recorded.append(args),
  1364. )
  1365. await server._publish_to_report(writer, {"print": {"command": "push_status"}}, log_event=False)
  1366. assert recorded == []
  1367. # ---------------------------------------------------------------------------
  1368. # Routing: _handle_publish
  1369. # ---------------------------------------------------------------------------
  1370. class TestPublishRouting:
  1371. """Slicer-issued commands: project_file/gcode_file handled locally, everything
  1372. else forwarded to the real printer."""
  1373. def _build_publish_payload(self, topic: str, body: bytes) -> bytes:
  1374. topic_bytes = topic.encode("utf-8")
  1375. return bytes([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF]) + topic_bytes + body
  1376. def _attach_active_bridge(self, server: SimpleMQTTServer) -> MagicMock:
  1377. bridge = MagicMock()
  1378. bridge.is_active = True
  1379. bridge.forward_to_printer = MagicMock(return_value=True)
  1380. server.set_bridge(bridge)
  1381. return bridge
  1382. @pytest.mark.asyncio
  1383. async def test_project_file_handled_locally_not_forwarded(self):
  1384. server = _make_server()
  1385. bridge = self._attach_active_bridge(server)
  1386. writer = MagicMock()
  1387. writer.write = MagicMock()
  1388. writer.drain = AsyncMock()
  1389. body = json.dumps({"print": {"command": "project_file", "subtask_name": "f", "sequence_id": "1"}}).encode()
  1390. payload = self._build_publish_payload(f"device/{VP_SERIAL}/request", body)
  1391. with patch.object(server, "_send_print_response", new=AsyncMock()) as mock_resp:
  1392. await server._handle_publish(0x30, payload, writer, "client1")
  1393. bridge.forward_to_printer.assert_not_called()
  1394. mock_resp.assert_awaited_once()
  1395. @pytest.mark.asyncio
  1396. async def test_gcode_file_handled_locally_not_forwarded(self):
  1397. server = _make_server()
  1398. bridge = self._attach_active_bridge(server)
  1399. writer = MagicMock()
  1400. writer.write = MagicMock()
  1401. writer.drain = AsyncMock()
  1402. body = json.dumps({"print": {"command": "gcode_file", "subtask_name": "f.gcode", "sequence_id": "1"}}).encode()
  1403. payload = self._build_publish_payload(f"device/{VP_SERIAL}/request", body)
  1404. with patch.object(server, "_send_print_response", new=AsyncMock()):
  1405. await server._handle_publish(0x30, payload, writer, "client1")
  1406. bridge.forward_to_printer.assert_not_called()
  1407. @pytest.mark.asyncio
  1408. async def test_pushall_handled_locally_not_forwarded(self):
  1409. server = _make_server()
  1410. bridge = self._attach_active_bridge(server)
  1411. writer = MagicMock()
  1412. writer.write = MagicMock()
  1413. writer.drain = AsyncMock()
  1414. body = json.dumps({"pushing": {"command": "pushall", "sequence_id": "0"}}).encode()
  1415. payload = self._build_publish_payload(f"device/{VP_SERIAL}/request", body)
  1416. with patch.object(server, "_send_status_report", new=AsyncMock()) as mock_status:
  1417. await server._handle_publish(0x30, payload, writer, "client1")
  1418. # Synthetic answer fires (fast, low latency); no forwarding (the
  1419. # cache already mirrors what the printer would respond with).
  1420. bridge.forward_to_printer.assert_not_called()
  1421. mock_status.assert_awaited_once()
  1422. @pytest.mark.asyncio
  1423. async def test_get_version_handled_locally_not_forwarded(self):
  1424. server = _make_server()
  1425. bridge = self._attach_active_bridge(server)
  1426. writer = MagicMock()
  1427. writer.write = MagicMock()
  1428. writer.drain = AsyncMock()
  1429. body = json.dumps({"info": {"command": "get_version", "sequence_id": "1"}}).encode()
  1430. payload = self._build_publish_payload(f"device/{VP_SERIAL}/request", body)
  1431. with patch.object(server, "_send_version_response", new=AsyncMock()) as mock_ver:
  1432. await server._handle_publish(0x30, payload, writer, "client1")
  1433. bridge.forward_to_printer.assert_not_called()
  1434. mock_ver.assert_awaited_once()
  1435. @pytest.mark.asyncio
  1436. async def test_extrusion_cali_get_is_forwarded(self):
  1437. """extrusion_cali_get fetches per-filament k-profiles — must reach the printer."""
  1438. server = _make_server()
  1439. bridge = self._attach_active_bridge(server)
  1440. writer = MagicMock()
  1441. writer.write = MagicMock()
  1442. writer.drain = AsyncMock()
  1443. body = json.dumps(
  1444. {
  1445. "print": {
  1446. "command": "extrusion_cali_get",
  1447. "filament_id": "",
  1448. "nozzle_diameter": "0.4",
  1449. "sequence_id": "5",
  1450. }
  1451. }
  1452. ).encode()
  1453. payload = self._build_publish_payload(f"device/{VP_SERIAL}/request", body)
  1454. await server._handle_publish(0x30, payload, writer, "client1")
  1455. bridge.forward_to_printer.assert_called_once()
  1456. forwarded = bridge.forward_to_printer.call_args.args[0]
  1457. assert forwarded["print"]["command"] == "extrusion_cali_get"
  1458. @pytest.mark.asyncio
  1459. async def test_print_stop_is_forwarded(self):
  1460. server = _make_server()
  1461. bridge = self._attach_active_bridge(server)
  1462. writer = MagicMock()
  1463. writer.write = MagicMock()
  1464. writer.drain = AsyncMock()
  1465. body = json.dumps({"print": {"command": "stop", "sequence_id": "5"}}).encode()
  1466. payload = self._build_publish_payload(f"device/{VP_SERIAL}/request", body)
  1467. await server._handle_publish(0x30, payload, writer, "client1")
  1468. bridge.forward_to_printer.assert_called_once()
  1469. # ---------------------------------------------------------------------------
  1470. # IP encoding helper
  1471. # ---------------------------------------------------------------------------
  1472. class TestIpEncoding:
  1473. def test_le_uint32_matches_real_h2d_capture(self):
  1474. # 192.168.255.133 captured from real H2D's net.info[0].ip = 2248124608
  1475. assert _ip_to_uint32_le("192.168.255.133") == 2248124608
  1476. def test_vp_ip_round_trip(self):
  1477. assert _ip_to_uint32_le("192.168.255.16") == 285190336
  1478. def test_invalid_ip_raises(self):
  1479. with pytest.raises(ValueError):
  1480. _ip_to_uint32_le("not.an.ip.actually")
  1481. class TestHostnameResolution:
  1482. """#1429 follow-up: users who configured the printer by FQDN (common on
  1483. LANs with router-provided DNS like `p1s.fritz.box`) hit `invalid IPv4`
  1484. on the encoder and the rewrite never armed — slicer kept FTPing direct
  1485. to the real printer. The bridge now resolves hostname→IPv4 first."""
  1486. def test_pass_through_for_valid_ipv4(self):
  1487. assert _resolve_target_to_ipv4("192.168.1.50") == "192.168.1.50"
  1488. def test_empty_returns_none(self):
  1489. assert _resolve_target_to_ipv4("") is None
  1490. assert _resolve_target_to_ipv4(None) is None # type: ignore[arg-type]
  1491. def test_hostname_resolves_via_getaddrinfo(self):
  1492. with patch(
  1493. "backend.app.services.virtual_printer.mqtt_bridge.socket.getaddrinfo",
  1494. return_value=[(2, 1, 6, "", ("192.168.3.153", 0))],
  1495. ) as mock_gai:
  1496. assert _resolve_target_to_ipv4("p1s.fritz.box") == "192.168.3.153"
  1497. # AF_INET filter prevents an IPv6-only result from being picked,
  1498. # since net.info[*].ip is a uint32 LE that can't carry v6.
  1499. assert mock_gai.call_args.kwargs.get("family") == socket.AF_INET
  1500. def test_dns_failure_returns_none(self):
  1501. with patch(
  1502. "backend.app.services.virtual_printer.mqtt_bridge.socket.getaddrinfo",
  1503. side_effect=OSError("Name or service not known"),
  1504. ):
  1505. assert _resolve_target_to_ipv4("nope.invalid") is None
  1506. def test_fqdn_target_arms_encoding(self, caplog):
  1507. """End-to-end: a client whose `ip_address` is an FQDN should arm
  1508. the bridge once DNS resolves, and the cached rewrite uses the
  1509. resolved IPv4 (not the hostname string) for the `net.info[].ip`
  1510. encoding."""
  1511. server = _make_server(bind_address=VP_IP)
  1512. bridge = _make_bridge(server)
  1513. client = _make_paho_client(ip="p1s.fritz.box")
  1514. bridge._target_client = client
  1515. with (
  1516. patch(
  1517. "backend.app.services.virtual_printer.mqtt_bridge.socket.getaddrinfo",
  1518. return_value=[(2, 1, 6, "", (H2D_IP, 0))],
  1519. ),
  1520. caplog.at_level(logging.INFO, logger="backend.app.services.virtual_printer.mqtt_bridge"),
  1521. ):
  1522. bridge._refresh_ip_encoding()
  1523. assert bridge._target_ip_uint32_le == _ip_to_uint32_le(H2D_IP)
  1524. assert bridge._vp_ip_uint32_le == _ip_to_uint32_le(VP_IP)
  1525. armed = [r for r in caplog.records if "MQTT bridge IP encoding armed" in r.getMessage()]
  1526. assert len(armed) == 1
  1527. # Operator should see configured→resolved in the log line so a
  1528. # bad-DNS regression is immediately legible.
  1529. assert "p1s.fritz.box→192.168.255.133" in armed[0].getMessage()
  1530. # ---------------------------------------------------------------------------
  1531. # Auto-resolve fallback for default-config (bind_address = "0.0.0.0")
  1532. # ---------------------------------------------------------------------------
  1533. class TestBindAddressAutoResolve:
  1534. """#1429 residual: VPs created without a dedicated bind IP run on
  1535. `bind_address=0.0.0.0`. The original fix's `_refresh_ip_encoding`
  1536. early-returned on 0.0.0.0, so the rewrite never armed and `net.info[].ip`
  1537. kept leaking the real printer IP. Now the bridge auto-resolves a host
  1538. interface in the printer's subnet and uses that as the VP IP."""
  1539. @pytest.mark.asyncio
  1540. async def test_rewrite_arms_via_auto_resolved_host_ip(self):
  1541. """When bind_address is 0.0.0.0, fall back to the host interface in
  1542. the target printer's subnet and rewrite to that IP."""
  1543. server = _make_server(bind_address="0.0.0.0") # nosec B104
  1544. bridge = _make_bridge(server)
  1545. with patch(
  1546. "backend.app.services.virtual_printer.mqtt_bridge._resolve_host_interface_for_target",
  1547. return_value=VP_IP,
  1548. ):
  1549. await bridge.start()
  1550. h2d_le = _ip_to_uint32_le(H2D_IP)
  1551. vp_le = _ip_to_uint32_le(VP_IP)
  1552. payload = json.dumps(
  1553. {
  1554. "print": {
  1555. "command": "push_status",
  1556. "net": {"info": [{"ip": h2d_le, "mask": 0xFFFFFF}]},
  1557. }
  1558. }
  1559. ).encode()
  1560. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", payload)
  1561. await asyncio.sleep(0.01)
  1562. cached = bridge.get_latest_print_state()
  1563. assert cached["net"]["info"][0]["ip"] == vp_le
  1564. assert bridge._vp_ip_uint32_le == vp_le
  1565. await bridge.stop()
  1566. @pytest.mark.asyncio
  1567. async def test_rewrite_disabled_when_no_matching_host_interface(self):
  1568. """If no host interface shares a subnet with the printer, the bridge
  1569. cannot pick a sensible VP IP — leave encoding unarmed and let the
  1570. push through unrewritten (no crash, no wrong rewrite)."""
  1571. server = _make_server(bind_address="")
  1572. bridge = _make_bridge(server)
  1573. with patch(
  1574. "backend.app.services.virtual_printer.mqtt_bridge._resolve_host_interface_for_target",
  1575. return_value=None,
  1576. ):
  1577. await bridge.start()
  1578. h2d_le = _ip_to_uint32_le(H2D_IP)
  1579. payload = json.dumps(
  1580. {
  1581. "print": {
  1582. "command": "push_status",
  1583. "net": {"info": [{"ip": h2d_le, "mask": 0xFFFFFF}]},
  1584. }
  1585. }
  1586. ).encode()
  1587. bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", payload)
  1588. await asyncio.sleep(0.01)
  1589. assert bridge._vp_ip_uint32_le is None
  1590. assert bridge._target_ip_uint32_le is None
  1591. await bridge.stop()
  1592. @pytest.mark.asyncio
  1593. async def test_explicit_bind_ip_takes_precedence_over_auto_resolve(self):
  1594. """Auto-resolve only kicks in when bind_address is empty/0.0.0.0; an
  1595. explicitly-set bind IP must be used verbatim even if there's also a
  1596. same-subnet host interface."""
  1597. server = _make_server(bind_address=VP_IP)
  1598. bridge = _make_bridge(server)
  1599. # Auto-resolver would have returned a DIFFERENT IP — we must not use it.
  1600. with patch(
  1601. "backend.app.services.virtual_printer.mqtt_bridge._resolve_host_interface_for_target",
  1602. return_value="10.99.99.99",
  1603. ):
  1604. await bridge.start()
  1605. assert bridge._vp_ip_uint32_le == _ip_to_uint32_le(VP_IP)
  1606. await bridge.stop()
  1607. def test_resolve_helper_returns_none_for_unreachable_target(self):
  1608. """The helper itself must be defensive — if `find_interface_for_ip`
  1609. raises or returns None, we get None (no crash)."""
  1610. with patch(
  1611. "backend.app.services.network_utils.find_interface_for_ip",
  1612. return_value=None,
  1613. ):
  1614. assert _resolve_host_interface_for_target("203.0.113.1") is None
  1615. class TestNotArmedDiagnosticLogging:
  1616. """#1429 follow-up: every silent early-return in `_refresh_ip_encoding`
  1617. now emits one INFO line explaining WHY the rewrite couldn't arm. Throttled
  1618. to one line per state change so an idle unarmed bridge doesn't spam the
  1619. log every 30s tick. Cleared on arm so a future failure re-emits.
  1620. """
  1621. def test_no_client_logs_once(self, caplog):
  1622. bridge = _make_bridge(_make_server())
  1623. # Force the "no client" path: bridge starts with _target_client=None.
  1624. assert bridge._target_client is None
  1625. with caplog.at_level(logging.INFO, logger="backend.app.services.virtual_printer.mqtt_bridge"):
  1626. bridge._refresh_ip_encoding()
  1627. bridge._refresh_ip_encoding() # 2nd tick — same reason, must NOT re-log.
  1628. bridge._refresh_ip_encoding()
  1629. not_armed = [r for r in caplog.records if "NOT armed" in r.getMessage()]
  1630. assert len(not_armed) == 1
  1631. assert "target_client is None" in not_armed[0].getMessage()
  1632. def test_missing_target_ip_logs_specific_reason(self, caplog):
  1633. bridge = _make_bridge(_make_server())
  1634. # Manually attach a client with no ip_address (simulates pre-DHCP).
  1635. client = _make_paho_client()
  1636. client.ip_address = ""
  1637. bridge._target_client = client
  1638. with caplog.at_level(logging.INFO, logger="backend.app.services.virtual_printer.mqtt_bridge"):
  1639. bridge._refresh_ip_encoding()
  1640. not_armed = [r for r in caplog.records if "NOT armed" in r.getMessage()]
  1641. assert len(not_armed) == 1
  1642. assert "no ip_address" in not_armed[0].getMessage()
  1643. def test_no_matching_host_interface_logs_specific_reason(self, caplog):
  1644. server = _make_server(bind_address="0.0.0.0") # nosec B104
  1645. bridge = _make_bridge(server)
  1646. with (
  1647. patch(
  1648. "backend.app.services.virtual_printer.mqtt_bridge._resolve_host_interface_for_target",
  1649. return_value=None,
  1650. ),
  1651. caplog.at_level(logging.INFO, logger="backend.app.services.virtual_printer.mqtt_bridge"),
  1652. ):
  1653. bridge._target_client = _make_paho_client()
  1654. bridge._refresh_ip_encoding()
  1655. not_armed = [r for r in caplog.records if "NOT armed" in r.getMessage()]
  1656. assert len(not_armed) == 1
  1657. msg = not_armed[0].getMessage()
  1658. assert H2D_IP in msg
  1659. assert "no host interface" in msg
  1660. def test_unresolvable_target_logs_reason(self, caplog):
  1661. """When `ip_address` isn't a valid IPv4 *and* doesn't resolve via DNS,
  1662. the bridge must report a single concrete not-armed reason naming the
  1663. configured value — operator can then see exactly what input failed."""
  1664. server = _make_server(bind_address=VP_IP)
  1665. bridge = _make_bridge(server)
  1666. client = _make_paho_client()
  1667. client.ip_address = "not.an.ip"
  1668. bridge._target_client = client
  1669. with (
  1670. patch(
  1671. "backend.app.services.virtual_printer.mqtt_bridge.socket.getaddrinfo",
  1672. side_effect=OSError("nodename nor servname provided"),
  1673. ),
  1674. caplog.at_level(logging.INFO, logger="backend.app.services.virtual_printer.mqtt_bridge"),
  1675. ):
  1676. bridge._refresh_ip_encoding()
  1677. not_armed = [r for r in caplog.records if "NOT armed" in r.getMessage()]
  1678. assert len(not_armed) == 1
  1679. assert "could not resolve printer host 'not.an.ip'" in not_armed[0].getMessage()
  1680. def test_successful_arm_clears_dedup_so_future_failure_relogs(self, caplog):
  1681. """After a successful arm, the dedup must reset so a subsequent
  1682. regression (e.g. printer client unbinds) re-emits the diagnostic
  1683. line instead of being silenced by the previous failure reason."""
  1684. bridge = _make_bridge(_make_server(bind_address=VP_IP))
  1685. bridge._target_client = _make_paho_client()
  1686. with caplog.at_level(logging.INFO, logger="backend.app.services.virtual_printer.mqtt_bridge"):
  1687. bridge._refresh_ip_encoding() # arms
  1688. assert bridge._not_armed_reason is None
  1689. # Simulate a regression — target_client drops away.
  1690. bridge._target_client = None
  1691. bridge._refresh_ip_encoding()
  1692. bridge._refresh_ip_encoding() # 2nd same-reason tick must not re-log
  1693. not_armed = [r for r in caplog.records if "NOT armed" in r.getMessage()]
  1694. assert len(not_armed) == 1 # the post-arm failure
  1695. armed = [r for r in caplog.records if "MQTT bridge IP encoding armed" in r.getMessage()]
  1696. assert len(armed) == 1