mqtt_bridge.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755
  1. """MQTT bridge for non-proxy virtual printers.
  2. Mirrors the target printer's state to slicers connected to a virtual printer
  3. without opening a second MQTT session on the printer (reuses Bambuddy's
  4. existing subscription — firmware inflight budget unaffected, see PR #1164).
  5. Architecture (cached-as-base, not a separate fan-out stream):
  6. - **push_status** snapshots from the printer are CACHED here. The VP's
  7. `SimpleMQTTServer._send_status_report` consults that cache and sends
  8. a near-byte-identical copy of the real push to the slicer (with
  9. sequence_id / gcode_state / etc. overridden). Single source of truth
  10. keeps BambuStudio's Send pre-flight happy.
  11. - **info.get_version** responses are also cached so the synthetic version
  12. response can include the real AMS module list (n3f/n3s/ams entries).
  13. Without this BambuStudio's Prepare tab labels every AMS as "unknown".
  14. - **Other command responses** (extrusion_cali_get, AMS write acks,
  15. xcam responses, …) are fanned out raw to the slicer — they carry
  16. sequence_ids the slicer is waiting on; the slicer matches and ignores
  17. unrelated ones.
  18. Identity rewriting at cache time:
  19. - `upgrade_state.sn` (and any other nested dict's `sn` matching the real
  20. serial) → VP serial
  21. - `net.info[*].ip` little-endian uint32 → VP bind IP. BambuStudio reads
  22. this as the FTP destination IP. Without this the slicer FTPs straight
  23. to the real printer and bypasses Bambuddy.
  24. - `ipcam.rtsp_url` is left unchanged: BambuStudio overrides the URL host
  25. with the device IP it bound to (the VP), so the slicer hits the VP's
  26. own RTSPS proxy on port 322.
  27. """
  28. from __future__ import annotations
  29. import asyncio
  30. import copy
  31. import ipaddress
  32. import json
  33. import logging
  34. import socket
  35. from typing import TYPE_CHECKING
  36. from backend.app.services.bambu_mqtt import apply_tray_exist_bits
  37. from backend.app.services.virtual_printer._debug import append_event, dump_wire
  38. if TYPE_CHECKING:
  39. from backend.app.services.bambu_mqtt import BambuMQTTClient
  40. from backend.app.services.printer_manager import PrinterManager
  41. from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
  42. logger = logging.getLogger(__name__)
  43. REFRESH_INTERVAL_SECONDS = 30.0
  44. # Bambuddy's internal printer state in bambu_mqtt.py (around line 2686+) is
  45. # updated per-field — each `if "X" in data: self.state.X = ...` block leaves
  46. # every other field untouched, so the state accumulates everything the
  47. # printer has ever sent. The bridge cache below mirrors that pattern: when
  48. # the incoming push_status omits a field, the previous value is preserved
  49. # verbatim; only fields actually present in the new push overwrite. This
  50. # stops capability/lifecycle fields (cali_version, print_type, mc_print_stage,
  51. # device, ...) draining out of the cache between pushalls, which surfaced
  52. # as #1622 (BambuStudio's Device-tab UIs greying out on P1S after the
  53. # cache drained to a thin incremental snapshot). The `ams` field still
  54. # gets unit-/tray-level deep merge via `_merge_ams_dict` because firmware
  55. # sends partial `ams` blobs under the same key (#1387).
  56. def _ip_to_uint32_le(ip_str: str) -> int:
  57. """Encode dotted-quad IPv4 as little-endian uint32 (Bambu MQTT's `net.info[].ip` shape)."""
  58. parts = [int(x) for x in ip_str.split(".")]
  59. if len(parts) != 4 or any(p < 0 or p > 255 for p in parts):
  60. raise ValueError(f"invalid IPv4: {ip_str!r}")
  61. return parts[0] | (parts[1] << 8) | (parts[2] << 16) | (parts[3] << 24)
  62. def _resolve_target_to_ipv4(target: str) -> str | None:
  63. """Return a dotted-quad IPv4 for `target`, resolving hostnames if needed.
  64. The printer client may be configured by IPv4 *or* by hostname/FQDN
  65. (e.g. `p1s.fritz.box`) — the latter is common on home LANs with a
  66. DNS-providing router. The downstream `net.info[].ip` field is a
  67. 32-bit little-endian integer though, so a hostname can't round-trip
  68. through it; we have to pick *one* concrete IPv4 to write in.
  69. Returns None if `target` is empty, not parseable as IPv4, and DNS
  70. resolution fails — caller logs that as the not-armed reason and
  71. re-tries on the next refresh tick (DHCP/DNS churn picks itself up).
  72. """
  73. if not target:
  74. return None
  75. try:
  76. return str(ipaddress.IPv4Address(target))
  77. except (ValueError, ipaddress.AddressValueError):
  78. pass
  79. try:
  80. # AF_INET filters to IPv4 only; the rewrite field is uint32 LE,
  81. # there's no IPv6 representation that fits.
  82. infos = socket.getaddrinfo(target, None, family=socket.AF_INET)
  83. except OSError:
  84. return None
  85. for info in infos:
  86. sockaddr = info[4]
  87. if sockaddr and isinstance(sockaddr[0], str):
  88. return sockaddr[0]
  89. return None
  90. def _resolve_host_interface_for_target(target_ip: str) -> str | None:
  91. """Pick a host-side IPv4 for `net.info[].ip` when the VP has no dedicated bind IP.
  92. Used when `mqtt_server.bind_address` is empty or 0.0.0.0 — the listener
  93. accepts on every interface but we still need ONE concrete IPv4 to write
  94. into the rewritten `net.info[].ip` field so the slicer's FTP target
  95. resolves to Bambuddy rather than the real printer. Returns the IPv4 of
  96. the host interface that shares a subnet with the printer (best fit
  97. because the slicer is typically on the same LAN as the printer), or
  98. None if no interface matches — in which case the bridge leaves
  99. encoding unarmed and the previous (still-leaky) behaviour stands.
  100. """
  101. try:
  102. from backend.app.services.network_utils import find_interface_for_ip
  103. except Exception: # pragma: no cover - import shielding
  104. return None
  105. try:
  106. iface = find_interface_for_ip(target_ip)
  107. except Exception:
  108. logger.exception("MQTT bridge: find_interface_for_ip(%s) crashed", target_ip)
  109. return None
  110. if not iface:
  111. return None
  112. ip = iface.get("ip")
  113. return ip if isinstance(ip, str) and ip else None
  114. def _merge_ams_dict(prev_ams: dict, new_ams: dict) -> dict:
  115. """Merge a new ``ams`` blob from an incremental push onto the previous one.
  116. Bambu firmware sends three shapes for the ``ams`` field on push_status:
  117. 1. Full pushall (after a printer reconnect or explicit pushall request):
  118. ``{ams: [{id, tray: [{id, tray_type, ...}, ...]}, ...], ams_status, ams_exist_bits, ...}``
  119. — every unit + every tray populated.
  120. 2. Status-only incremental: ``{ams_status: 1}`` or ``{humidity: 30}`` —
  121. no ``ams`` array at all. Bambuddy logs these as "AMS partial update
  122. (no tray data)" (#784 vintage).
  123. 3. Tray-targeted incremental during a print: ``{ams: [{id: 0, tray:
  124. [{id: 0, state: 11}]}]}`` — only the units / trays whose state
  125. changed.
  126. Replacing the cached ``ams`` wholesale on shapes (2) and (3) is what
  127. made the slicer "lose" AMS between pushalls and trip the symptom in
  128. #1387: the slicer would see a stripped ``ams_status``-only blob and
  129. fall back to its "no AMS" default render. This merge mirrors the
  130. deep-merge logic in ``bambu_mqtt.py::_handle_ams_data`` at the bridge
  131. layer so the slicer-facing cache always carries the latest known
  132. coherent state.
  133. Strategy:
  134. - Shallow-merge top-level scalars: keys in ``new`` win; keys only
  135. in ``prev`` are preserved.
  136. - For the ``ams`` array (list of units): match by ``id``. Units
  137. only in ``prev`` survive. Units in ``new`` overlay onto their
  138. ``prev`` counterpart; same recursion applies to each unit's
  139. ``tray`` array by tray ``id``.
  140. """
  141. merged = dict(prev_ams)
  142. for k, v in new_ams.items():
  143. if k != "ams":
  144. merged[k] = v
  145. prev_units = prev_ams.get("ams") if isinstance(prev_ams.get("ams"), list) else []
  146. new_units = new_ams.get("ams") if isinstance(new_ams.get("ams"), list) else None
  147. if new_units is None:
  148. # Shape (2): no ``ams`` array in the incremental — keep prev's units.
  149. if prev_units:
  150. merged["ams"] = prev_units
  151. return merged
  152. prev_by_id = {u.get("id"): u for u in prev_units if isinstance(u, dict) and u.get("id") is not None}
  153. merged_units: list = []
  154. seen_ids: set = set()
  155. for new_unit in new_units:
  156. if not isinstance(new_unit, dict):
  157. merged_units.append(new_unit)
  158. continue
  159. uid = new_unit.get("id")
  160. prev_unit = prev_by_id.get(uid) if uid is not None else None
  161. if prev_unit is None:
  162. merged_units.append(new_unit)
  163. if uid is not None:
  164. seen_ids.add(uid)
  165. continue
  166. # Shallow-merge unit fields; preserve prev's trays not present in new.
  167. merged_unit = dict(prev_unit)
  168. for k, v in new_unit.items():
  169. if k != "tray":
  170. merged_unit[k] = v
  171. new_trays = new_unit.get("tray") if isinstance(new_unit.get("tray"), list) else None
  172. if new_trays is None:
  173. # Unit-level partial — keep prev's tray list intact.
  174. pass
  175. else:
  176. prev_trays = prev_unit.get("tray") if isinstance(prev_unit.get("tray"), list) else []
  177. prev_trays_by_id = {t.get("id"): t for t in prev_trays if isinstance(t, dict) and t.get("id") is not None}
  178. merged_trays: list = []
  179. seen_tray_ids: set = set()
  180. for new_tray in new_trays:
  181. if not isinstance(new_tray, dict):
  182. merged_trays.append(new_tray)
  183. continue
  184. tid = new_tray.get("id")
  185. prev_tray = prev_trays_by_id.get(tid) if tid is not None else None
  186. if prev_tray is None:
  187. merged_trays.append(new_tray)
  188. else:
  189. merged_tray = dict(prev_tray)
  190. merged_tray.update(new_tray)
  191. merged_trays.append(merged_tray)
  192. if tid is not None:
  193. seen_tray_ids.add(tid)
  194. # Preserve prev trays not mentioned in the incremental.
  195. for tid, prev_tray in prev_trays_by_id.items():
  196. if tid not in seen_tray_ids:
  197. merged_trays.append(prev_tray)
  198. merged_unit["tray"] = merged_trays
  199. merged_units.append(merged_unit)
  200. if uid is not None:
  201. seen_ids.add(uid)
  202. # Preserve prev units not mentioned in the incremental.
  203. for uid, prev_unit in prev_by_id.items():
  204. if uid not in seen_ids:
  205. merged_units.append(prev_unit)
  206. merged["ams"] = merged_units
  207. return merged
  208. class MQTTBridge:
  209. """Per-VP MQTT fan-out between a real printer and slicers connected to a VP."""
  210. def __init__(
  211. self,
  212. *,
  213. vp_id: int,
  214. vp_name: str,
  215. vp_serial: str,
  216. target_printer_id: int,
  217. mqtt_server: SimpleMQTTServer,
  218. printer_manager: PrinterManager,
  219. ):
  220. self.vp_id = vp_id
  221. self.vp_name = vp_name
  222. self.vp_serial = vp_serial
  223. self.target_printer_id = target_printer_id
  224. self._mqtt_server = mqtt_server
  225. self._printer_manager = printer_manager
  226. self._target_client: BambuMQTTClient | None = None
  227. self._target_serial: str | None = None
  228. self._target_ip_uint32_le: int | None = None
  229. self._vp_ip_uint32_le: int | None = None
  230. # Last reason `_refresh_ip_encoding` early-returned without arming.
  231. # Used to throttle the "NOT armed" diagnostic log to one line per
  232. # state change — refresh runs every 30s, so without throttling an
  233. # idle-but-unarmed bridge would emit one line per tick forever. Set
  234. # to None once arming succeeds so the next failure re-logs. #1429
  235. # follow-up: makes silent early-returns visible without grepping the
  236. # source.
  237. self._not_armed_reason: str | None = None
  238. self._loop: asyncio.AbstractEventLoop | None = None
  239. self._refresh_task: asyncio.Task | None = None
  240. self._stopping = False
  241. self._latest_print_state: dict | None = None
  242. self._latest_version_modules: list | None = None
  243. @property
  244. def is_active(self) -> bool:
  245. """True iff a target client is bound and currently connected."""
  246. client = self._target_client
  247. return bool(client is not None and getattr(client, "state", None) and client.state.connected)
  248. async def start(self) -> None:
  249. """Bind to the target printer (if connected) and start the refresh loop."""
  250. self._loop = asyncio.get_running_loop()
  251. self._stopping = False
  252. self._resolve_client()
  253. self._refresh_task = asyncio.create_task(self._refresh_loop())
  254. async def stop(self) -> None:
  255. """Detach from the target printer and stop the refresh loop."""
  256. self._stopping = True
  257. if self._refresh_task is not None:
  258. self._refresh_task.cancel()
  259. try:
  260. await self._refresh_task
  261. except asyncio.CancelledError:
  262. pass
  263. self._refresh_task = None
  264. self._unbind_client()
  265. self._loop = None
  266. async def _refresh_loop(self) -> None:
  267. """Re-resolve the target client periodically — paho clients can be replaced.
  268. BambuMQTTClient is destroyed and recreated on PrinterManager.connect_printer
  269. (e.g. printer config update). Without periodic refresh the bridge would lose
  270. fan-out after such a churn until the VP itself restarts.
  271. On crash exit, the handler must be unbound — otherwise the registered
  272. ``_on_printer_raw`` keeps firing on every real-printer message even
  273. though the bridge is functionally dead (memory leak + behaviour leak
  274. across VP restart).
  275. """
  276. try:
  277. while not self._stopping:
  278. await asyncio.sleep(REFRESH_INTERVAL_SECONDS)
  279. self._resolve_client()
  280. except asyncio.CancelledError:
  281. raise
  282. except Exception:
  283. logger.exception("[%s] MQTT bridge refresh loop crashed", self.vp_name)
  284. # Crash exit — unbind so the orphaned handler stops firing.
  285. # ``stop()`` won't be invoked because the task completes done-not-cancelled.
  286. self._unbind_client()
  287. def _resolve_client(self) -> None:
  288. """Look up the current client for target_printer_id and rebind if it changed."""
  289. try:
  290. current = self._printer_manager.get_client(self.target_printer_id)
  291. except Exception:
  292. logger.exception("[%s] MQTT bridge: get_client failed", self.vp_name)
  293. return
  294. if current is self._target_client:
  295. # Same client object — but `ip_address` can fill in *after* the
  296. # initial bind (e.g. DB row had a stale/empty value until the
  297. # client's first SSDP-driven IP refresh). The original code only
  298. # encoded `_target_ip_uint32_le` on client-identity change, so
  299. # that late-arriving IP was never picked up, the `net.info[*].ip`
  300. # rewrite stayed disabled, and the cache filled with the real
  301. # printer IP — #1429. Refresh the encoding every tick so it
  302. # self-heals once `ip_address` becomes valid.
  303. self._refresh_ip_encoding()
  304. return
  305. # Client identity changed — unregister from the old, register on the new.
  306. self._unbind_client()
  307. if current is None:
  308. return
  309. try:
  310. current.register_raw_message_handler(self._on_printer_raw)
  311. except Exception:
  312. logger.exception("[%s] MQTT bridge: register_raw_message_handler failed", self.vp_name)
  313. return
  314. self._target_client = current
  315. self._target_serial = getattr(current, "serial_number", None)
  316. self._refresh_ip_encoding()
  317. logger.info(
  318. "[%s] MQTT bridge bound to printer %s (serial=%s)",
  319. self.vp_name,
  320. self.target_printer_id,
  321. self._target_serial,
  322. )
  323. # Trigger a fresh get_version + pushall against the printer so the bridge
  324. # cache populates immediately. Bambuddy itself queries these on connect,
  325. # but that fires before the bridge attaches as a raw-message consumer,
  326. # so without this nudge the cache stays empty until the next periodic
  327. # query (which can be minutes away).
  328. #
  329. # The bind frequently races the real printer's MQTT TLS handshake — a
  330. # slicer-side reconnect re-resolves the client before the underlying
  331. # session has reconnected, especially on A1 firmware where the bridge
  332. # cycles more aggressively (#1721). When that happens, the nudge is a
  333. # no-op — the next periodic pushall populates the cache anyway — but
  334. # `request_status_update` logs WARNING on the not-connected return path
  335. # and pollutes every support bundle with a benign line.
  336. #
  337. # Gate both nudges on the client being actually connected. The fall-
  338. # through path is unchanged: when the client comes up, the next
  339. # `_resolve_client` tick re-enters this branch on identity change OR
  340. # the periodic pushall in `bambu_mqtt.py` fills the cache.
  341. client_connected = bool(getattr(getattr(current, "state", None), "connected", False))
  342. if not client_connected:
  343. logger.debug(
  344. "[%s] MQTT bridge: post-bind nudge skipped (printer client not connected yet)",
  345. self.vp_name,
  346. )
  347. else:
  348. request_fn = getattr(current, "_request_version", None)
  349. if callable(request_fn):
  350. try:
  351. request_fn()
  352. except Exception:
  353. logger.exception("[%s] MQTT bridge: _request_version failed", self.vp_name)
  354. request_status_fn = getattr(current, "request_status_update", None)
  355. if callable(request_status_fn):
  356. try:
  357. request_status_fn()
  358. except Exception:
  359. logger.exception("[%s] MQTT bridge: request_status_update failed", self.vp_name)
  360. def _unbind_client(self) -> None:
  361. if self._target_client is None:
  362. return
  363. try:
  364. self._target_client.unregister_raw_message_handler(self._on_printer_raw)
  365. except Exception:
  366. logger.exception("[%s] MQTT bridge: unregister_raw_message_handler failed", self.vp_name)
  367. logger.info("[%s] MQTT bridge unbound from printer %s", self.vp_name, self.target_printer_id)
  368. self._target_client = None
  369. self._target_serial = None
  370. def _refresh_ip_encoding(self) -> None:
  371. """(Re-)encode `_target_ip_uint32_le` / `_vp_ip_uint32_le` from current values.
  372. Called on every refresh tick, not just on client-identity change, so
  373. a late-arriving printer IP (or a bind-address change) is picked up
  374. without restarting the VP. When the encoding becomes valid for the
  375. first time *after* the cache already received a push with the real
  376. printer IP, also sweep the existing cache so the slicer's next pull
  377. sees the rewritten value (#1429). Without this sweep the sticky-key
  378. preservation keeps the poisoned `net.info[].ip` alive forever.
  379. VP bind IP resolution: when `mqtt_server.bind_address` is empty or
  380. `0.0.0.0` (the default for VPs that were never assigned a dedicated
  381. bind IP), fall back to auto-resolving the host interface in the same
  382. subnet as the printer's IP. Without this fallback, the rewrite never
  383. arms on a default-config flat-LAN install and `net.info[].ip` leaks
  384. the real printer IP — slicer follows it on Send (#1429 residual).
  385. """
  386. def _log_not_armed(reason: str) -> None:
  387. # Throttle: only log when the reason changes, otherwise an idle
  388. # unarmed bridge would emit one INFO line every refresh tick
  389. # (~30s) forever. Cleared on arm so a regression re-logs.
  390. if reason != self._not_armed_reason:
  391. logger.info("[%s] MQTT bridge IP encoding NOT armed: %s", self.vp_name, reason)
  392. self._not_armed_reason = reason
  393. client = self._target_client
  394. if client is None:
  395. _log_not_armed("target_client is None (bridge not bound to a printer)")
  396. return
  397. configured_target = getattr(client, "ip_address", None)
  398. if not configured_target:
  399. _log_not_armed("printer client has no ip_address yet")
  400. return
  401. # Printers configured by hostname/FQDN (e.g. `p1s.fritz.box`) need to
  402. # be resolved to an IPv4 before encoding: net.info[*].ip is uint32 LE
  403. # and can't carry a hostname (#1429 follow-up).
  404. target_ip = _resolve_target_to_ipv4(configured_target)
  405. if not target_ip:
  406. _log_not_armed(
  407. f"could not resolve printer host {configured_target!r} to IPv4 (invalid address and DNS lookup failed)"
  408. )
  409. return
  410. vp_ip = getattr(self._mqtt_server, "bind_address", None)
  411. vp_ip_source = "bind_address"
  412. if not vp_ip or vp_ip in ("0.0.0.0", ""): # nosec B104
  413. resolved = _resolve_host_interface_for_target(target_ip)
  414. if not resolved:
  415. _log_not_armed(
  416. f"no host interface shares a subnet with printer IP {target_ip} "
  417. "(and VP bind_address is 0.0.0.0/empty)"
  418. )
  419. return
  420. vp_ip = resolved
  421. vp_ip_source = "auto-resolved"
  422. try:
  423. new_target_le = _ip_to_uint32_le(target_ip)
  424. new_vp_le = _ip_to_uint32_le(vp_ip)
  425. except ValueError as e:
  426. _log_not_armed(f"invalid IPv4 (target={target_ip!r}, vp={vp_ip!r}): {e}")
  427. return
  428. if new_target_le == self._target_ip_uint32_le and new_vp_le == self._vp_ip_uint32_le:
  429. return # No change — nothing to do.
  430. # Encoding either became valid for the first time or shifted (DHCP
  431. # renewal, bind_ip reconfigured, etc.). Update + sweep the cache.
  432. was_armed = self._target_ip_uint32_le is not None and self._vp_ip_uint32_le is not None
  433. self._target_ip_uint32_le = new_target_le
  434. self._vp_ip_uint32_le = new_vp_le
  435. # Clear the dedup so a future failure re-emits the diagnostic line.
  436. self._not_armed_reason = None
  437. target_display = target_ip if target_ip == configured_target else f"{configured_target}→{target_ip}"
  438. logger.info(
  439. "[%s] MQTT bridge IP encoding %s: target=%s vp=%s (%s)",
  440. self.vp_name,
  441. "updated" if was_armed else "armed",
  442. target_display,
  443. vp_ip,
  444. vp_ip_source,
  445. )
  446. cached = self._latest_print_state
  447. if isinstance(cached, dict):
  448. n = self._rewrite_net_info_ips(cached)
  449. if n:
  450. logger.info(
  451. "[%s] MQTT bridge swept %d net.info[].ip entries in cached push",
  452. self.vp_name,
  453. n,
  454. )
  455. def _rewrite_net_info_ips(self, print_state: dict) -> int:
  456. """Rewrite every non-zero `net.info[].ip` in `print_state` to the VP bind IP.
  457. Returns the number of entries rewritten. Mutates `print_state` in place.
  458. Strategy: rewrite ALL entries with a non-zero `ip`, not only those
  459. matching `_target_ip_uint32_le`. Real printers (X1C, H2D Pro) can
  460. report multiple active interfaces (WiFi + Ethernet) with different
  461. IPs — only one matches the IP Bambuddy tracks, but the slicer may
  462. read any of them. Leaving non-matching entries pointing at real
  463. printer interfaces leaks an FTP fallback path that bypasses the VP
  464. (the #1429 / #1302 symptom). Entries with `ip == 0` are placeholders
  465. for unpopulated interfaces — leave them alone so the slicer's
  466. "active interface" detection still recognises them as absent.
  467. """
  468. if self._vp_ip_uint32_le is None:
  469. return 0
  470. net = print_state.get("net")
  471. if not isinstance(net, dict):
  472. return 0
  473. info = net.get("info")
  474. if not isinstance(info, list):
  475. return 0
  476. rewritten = 0
  477. for entry in info:
  478. if not isinstance(entry, dict):
  479. continue
  480. ip_value = entry.get("ip")
  481. if not isinstance(ip_value, int) or ip_value == 0:
  482. continue
  483. if ip_value == self._vp_ip_uint32_le:
  484. continue
  485. entry["ip"] = self._vp_ip_uint32_le
  486. rewritten += 1
  487. return rewritten
  488. def _on_printer_raw(self, topic: str, payload: bytes) -> None:
  489. """Paho-thread callback — cache the latest push_status for synthetic replay.
  490. Instead of fanning out a second stream of MQTT messages to the slicer
  491. (which trips BambuStudio's Send pre-flight consistency checks), we cache
  492. the latest real printer push_status here. The VP's existing 1 Hz
  493. synthetic push (which is what Send is built around) consults this cache
  494. and replaces its stub fields with real values when available.
  495. """
  496. if self._stopping:
  497. return
  498. target_serial = self._target_serial
  499. if not target_serial:
  500. return
  501. prefix = f"device/{target_serial}/"
  502. if not topic.startswith(prefix):
  503. return
  504. suffix = topic[len(prefix) :]
  505. if not suffix.startswith("report"):
  506. return
  507. try:
  508. data = json.loads(payload)
  509. except json.JSONDecodeError:
  510. return
  511. # Race-free by construction: `json.loads` returns a fresh dict tree per
  512. # call so paho-thread mutations below cannot collide with prior cached
  513. # state held by the asyncio thread. `_send_status_report`'s shallow
  514. # `dict(cached)` is also safe because nothing else writes to the cached
  515. # tree after assignment. The defensive deep-copy on store below removes
  516. # any future risk if a maintainer later re-enters the cached dict to
  517. # mutate it.
  518. # push_status snapshots → cache the print dict for the periodic 1 Hz
  519. # cached-as-base delivery. We do NOT fan these out separately (the
  520. # 1 Hz cached-as-base IS the slicer-facing push_status stream).
  521. print_data = data.get("print")
  522. if isinstance(print_data, dict) and print_data.get("command") == "push_status":
  523. for value in print_data.values():
  524. if isinstance(value, dict) and value.get("sn") == target_serial:
  525. value["sn"] = self.vp_serial
  526. # Note: `ipcam.rtsp_url` carries the real printer's IP. We pass it
  527. # through unchanged — the slicer uses it to fetch the live camera
  528. # stream directly from the printer. On the same LAN this works as
  529. # long as the slicer's stored access code matches the printer's
  530. # (i.e. configure the VP with the same access code as its target).
  531. # Rewrite real printer IP → VP bind IP in `net.info[*].ip` so the
  532. # slicer's FTP destination resolves to the VP, not the real printer.
  533. self._rewrite_net_info_ips(print_data)
  534. # Defensive deep copy on store so the cache is fully decoupled from
  535. # the freshly-parsed tree and from any reader's reference.
  536. new_state = copy.deepcopy(print_data)
  537. # Bambu firmware sends two kinds of push_status: full pushall
  538. # responses (on `pushall` requests / printer reconnect) which
  539. # include the full top-level field set (AMS, vt_tray, net,
  540. # cali_version, print_type, mc_print_stage, device, ...) — and
  541. # ~1 Hz incrementals with just the fields that changed (temps,
  542. # fan, wifi, status). Carry over every prev field the incoming
  543. # push doesn't overwrite, mirroring the per-field accumulate
  544. # pattern in bambu_mqtt.py's internal state handler — without
  545. # this the cache thins out to whatever the latest incremental
  546. # carried (~17 keys on P1S in #1622), and the slicer's Device-
  547. # tab capability gates (manage-calibration, AMS-assign dropdown,
  548. # …) flip off because their gating fields drained from the
  549. # cache. The deep-copy is defensive: without it the carried-
  550. # over nested dicts/lists are shared with the previous cache,
  551. # so any in-place mutation later would corrupt both.
  552. prev = self._latest_print_state
  553. if prev is not None:
  554. for prev_key, prev_value in prev.items():
  555. if prev_key not in new_state:
  556. new_state[prev_key] = copy.deepcopy(prev_value)
  557. # Firmware sends partial `ams` blobs (status-only / unit-
  558. # targeted / tray-targeted) under the same key on
  559. # incremental updates, which would overwrite the cached
  560. # full blob and break the slicer's AMS render (#1387 /
  561. # #1371). Deep-merge mirrors what bambu_mqtt.py does
  562. # internally in `_handle_ams_data`.
  563. if isinstance(new_state.get("ams"), dict) and isinstance(prev.get("ams"), dict):
  564. new_state["ams"] = _merge_ams_dict(prev["ams"], new_state["ams"])
  565. # Same per-field accumulate rule applied one level deeper for
  566. # other top-level dict-shaped fields. Firmware sends partial
  567. # `vt_tray` (external spool) updates right after a slicer
  568. # `ams_filament_setting` pick — typically just `{tray_info_idx,
  569. # tray_color}`, dropping the ~18 other fields (`tray_type`,
  570. # `state`, `remain`, `k`, `n`, `cali_idx`, `nozzle_temp_min/max`,
  571. # `tray_uuid`, `xcam_info`, ...) the slicer needs to render the
  572. # slot. Without overlay the next 1 Hz cached-as-base push
  573. # delivered the stripped dict and the slicer rendered the
  574. # external slot as "invalid" until a reload triggered a fresh
  575. # pushall (#1622 round 5, reported by @shaddowlink). AMS slots
  576. # didn't suffer because `_merge_ams_dict` deep-merges per tray.
  577. # Same shape covers `device`, `online`, `upgrade_state`, `ipcam`,
  578. # `upload`, `net`, ... against future firmware partials too.
  579. # `ams` is excluded — already deep-merged above.
  580. for key, new_value in list(new_state.items()):
  581. if key == "ams":
  582. continue
  583. prev_value = prev.get(key)
  584. if isinstance(prev_value, dict) and isinstance(new_value, dict):
  585. merged = dict(prev_value)
  586. merged.update(new_value)
  587. new_state[key] = merged
  588. # Apply empty-slot cleanup on the merged AMS so the slicer-facing
  589. # cache mirrors what Bambuddy's AMS card shows internally. Without
  590. # this the cached units carry stale per-tray filament fields for
  591. # slots whose `tray_exist_bits` bit is 0, and BambuStudio's Sync
  592. # paints those empty slots as phantom loaded filaments (#1726).
  593. # Runs whether or not a prev cache existed — fresh pushalls also
  594. # carry tray_exist_bits and benefit from the cleanup.
  595. # These units carry the RAW firmware ids — this cache is what the
  596. # slicer sees, and BambuStudio addresses the A2L's AMS-Lite as the
  597. # physical id 16 (it sends `ams_get_rfid {ams_id: 16}` through the
  598. # VP), so we must not normalise them to 6 the way Bambuddy's
  599. # internal state does. `apply_tray_exist_bits` folds 16 onto the
  600. # same bit base internally instead (#2697).
  601. merged_ams_dict = new_state.get("ams")
  602. if isinstance(merged_ams_dict, dict):
  603. units = merged_ams_dict.get("ams")
  604. apply_tray_exist_bits(
  605. units if isinstance(units, list) else [],
  606. merged_ams_dict.get("tray_exist_bits"),
  607. power_on_flag=merged_ams_dict.get("power_on_flag", True),
  608. log_label=self.vp_name,
  609. )
  610. self._latest_print_state = new_state
  611. dump_wire(self.vp_name, "in", new_state)
  612. return
  613. # info.get_version responses → cache the module list so the synthetic
  614. # version response can include the real AMS modules.
  615. info_data = data.get("info")
  616. if isinstance(info_data, dict) and info_data.get("command") == "get_version":
  617. modules = info_data.get("module")
  618. if isinstance(modules, list):
  619. rewritten: list = []
  620. for module in modules:
  621. if isinstance(module, dict):
  622. module = dict(module)
  623. if module.get("sn") == target_serial:
  624. module["sn"] = self.vp_serial
  625. rewritten.append(module)
  626. self._latest_version_modules = rewritten
  627. # Don't fan out get_version — the slicer's request (when it issues
  628. # one) is intercepted locally and answered from the cached modules.
  629. return
  630. # Everything else (extrusion_cali_get response, AMS write acks, xcam
  631. # responses, …): fan out to the slicer. These are responses to commands
  632. # the slicer (or Bambuddy) issued; the slicer matches by sequence_id and
  633. # ignores responses to commands it didn't send. Without this, slicer-
  634. # initiated queries like extrusion_cali_get hang forever and BambuStudio
  635. # blocks Send waiting for the response.
  636. loop = self._loop
  637. if loop is None:
  638. return
  639. target_bytes = target_serial.encode("ascii")
  640. if target_bytes in payload:
  641. payload = payload.replace(target_bytes, self.vp_serial.encode("ascii"))
  642. vp_topic = f"device/{self.vp_serial}/{suffix}"
  643. # Env-flagged command trace (#1622): every printer-originated response
  644. # that gets fanned to the slicer (extrusion_cali_get / ams write acks /
  645. # xcam / system / etc.) gets a line in vp_wire/<vp>_cmd.jsonl. Pair
  646. # with the slicer-side publishes captured in mqtt_server. Off by
  647. # default. Capture AFTER serial rewrite so the dump matches what the
  648. # slicer actually sees on the wire.
  649. append_event(self.vp_name, "printer_to_slicer", vp_topic, payload)
  650. try:
  651. asyncio.run_coroutine_threadsafe(
  652. self._mqtt_server.push_raw_to_clients(vp_topic, payload),
  653. loop,
  654. )
  655. except RuntimeError:
  656. pass
  657. def get_latest_print_state(self) -> dict | None:
  658. """Return the most recent real printer push_status `print` dict, or None."""
  659. return self._latest_print_state
  660. def get_latest_version_modules(self) -> list | None:
  661. """Return the most recent real printer get_version `module` list, or None."""
  662. return self._latest_version_modules
  663. def forward_to_printer(self, payload: dict) -> bool:
  664. """Publish a slicer-originated command to the real printer's request topic.
  665. Returns False if no printer client is currently bound.
  666. """
  667. client = self._target_client
  668. target_serial = self._target_serial
  669. if client is None or target_serial is None:
  670. logger.debug(
  671. "[%s] forward_to_printer dropped (printer %s not bound): %s",
  672. self.vp_name,
  673. self.target_printer_id,
  674. list(payload.keys()),
  675. )
  676. return False
  677. topic = f"device/{target_serial}/request"
  678. try:
  679. return client.publish_raw(topic, json.dumps(payload), qos=1)
  680. except Exception:
  681. logger.exception("[%s] forward_to_printer publish failed", self.vp_name)
  682. return False