| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755 |
- """MQTT bridge for non-proxy virtual printers.
- Mirrors the target printer's state to slicers connected to a virtual printer
- without opening a second MQTT session on the printer (reuses Bambuddy's
- existing subscription — firmware inflight budget unaffected, see PR #1164).
- Architecture (cached-as-base, not a separate fan-out stream):
- - **push_status** snapshots from the printer are CACHED here. The VP's
- `SimpleMQTTServer._send_status_report` consults that cache and sends
- a near-byte-identical copy of the real push to the slicer (with
- sequence_id / gcode_state / etc. overridden). Single source of truth
- keeps BambuStudio's Send pre-flight happy.
- - **info.get_version** responses are also cached so the synthetic version
- response can include the real AMS module list (n3f/n3s/ams entries).
- Without this BambuStudio's Prepare tab labels every AMS as "unknown".
- - **Other command responses** (extrusion_cali_get, AMS write acks,
- xcam responses, …) are fanned out raw to the slicer — they carry
- sequence_ids the slicer is waiting on; the slicer matches and ignores
- unrelated ones.
- Identity rewriting at cache time:
- - `upgrade_state.sn` (and any other nested dict's `sn` matching the real
- serial) → VP serial
- - `net.info[*].ip` little-endian uint32 → VP bind IP. BambuStudio reads
- this as the FTP destination IP. Without this the slicer FTPs straight
- to the real printer and bypasses Bambuddy.
- - `ipcam.rtsp_url` is left unchanged: BambuStudio overrides the URL host
- with the device IP it bound to (the VP), so the slicer hits the VP's
- own RTSPS proxy on port 322.
- """
- from __future__ import annotations
- import asyncio
- import copy
- import ipaddress
- import json
- import logging
- import socket
- from typing import TYPE_CHECKING
- from backend.app.services.bambu_mqtt import apply_tray_exist_bits
- from backend.app.services.virtual_printer._debug import append_event, dump_wire
- if TYPE_CHECKING:
- from backend.app.services.bambu_mqtt import BambuMQTTClient
- from backend.app.services.printer_manager import PrinterManager
- from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
- logger = logging.getLogger(__name__)
- REFRESH_INTERVAL_SECONDS = 30.0
- # Bambuddy's internal printer state in bambu_mqtt.py (around line 2686+) is
- # updated per-field — each `if "X" in data: self.state.X = ...` block leaves
- # every other field untouched, so the state accumulates everything the
- # printer has ever sent. The bridge cache below mirrors that pattern: when
- # the incoming push_status omits a field, the previous value is preserved
- # verbatim; only fields actually present in the new push overwrite. This
- # stops capability/lifecycle fields (cali_version, print_type, mc_print_stage,
- # device, ...) draining out of the cache between pushalls, which surfaced
- # as #1622 (BambuStudio's Device-tab UIs greying out on P1S after the
- # cache drained to a thin incremental snapshot). The `ams` field still
- # gets unit-/tray-level deep merge via `_merge_ams_dict` because firmware
- # sends partial `ams` blobs under the same key (#1387).
- def _ip_to_uint32_le(ip_str: str) -> int:
- """Encode dotted-quad IPv4 as little-endian uint32 (Bambu MQTT's `net.info[].ip` shape)."""
- parts = [int(x) for x in ip_str.split(".")]
- if len(parts) != 4 or any(p < 0 or p > 255 for p in parts):
- raise ValueError(f"invalid IPv4: {ip_str!r}")
- return parts[0] | (parts[1] << 8) | (parts[2] << 16) | (parts[3] << 24)
- def _resolve_target_to_ipv4(target: str) -> str | None:
- """Return a dotted-quad IPv4 for `target`, resolving hostnames if needed.
- The printer client may be configured by IPv4 *or* by hostname/FQDN
- (e.g. `p1s.fritz.box`) — the latter is common on home LANs with a
- DNS-providing router. The downstream `net.info[].ip` field is a
- 32-bit little-endian integer though, so a hostname can't round-trip
- through it; we have to pick *one* concrete IPv4 to write in.
- Returns None if `target` is empty, not parseable as IPv4, and DNS
- resolution fails — caller logs that as the not-armed reason and
- re-tries on the next refresh tick (DHCP/DNS churn picks itself up).
- """
- if not target:
- return None
- try:
- return str(ipaddress.IPv4Address(target))
- except (ValueError, ipaddress.AddressValueError):
- pass
- try:
- # AF_INET filters to IPv4 only; the rewrite field is uint32 LE,
- # there's no IPv6 representation that fits.
- infos = socket.getaddrinfo(target, None, family=socket.AF_INET)
- except OSError:
- return None
- for info in infos:
- sockaddr = info[4]
- if sockaddr and isinstance(sockaddr[0], str):
- return sockaddr[0]
- return None
- def _resolve_host_interface_for_target(target_ip: str) -> str | None:
- """Pick a host-side IPv4 for `net.info[].ip` when the VP has no dedicated bind IP.
- Used when `mqtt_server.bind_address` is empty or 0.0.0.0 — the listener
- accepts on every interface but we still need ONE concrete IPv4 to write
- into the rewritten `net.info[].ip` field so the slicer's FTP target
- resolves to Bambuddy rather than the real printer. Returns the IPv4 of
- the host interface that shares a subnet with the printer (best fit
- because the slicer is typically on the same LAN as the printer), or
- None if no interface matches — in which case the bridge leaves
- encoding unarmed and the previous (still-leaky) behaviour stands.
- """
- try:
- from backend.app.services.network_utils import find_interface_for_ip
- except Exception: # pragma: no cover - import shielding
- return None
- try:
- iface = find_interface_for_ip(target_ip)
- except Exception:
- logger.exception("MQTT bridge: find_interface_for_ip(%s) crashed", target_ip)
- return None
- if not iface:
- return None
- ip = iface.get("ip")
- return ip if isinstance(ip, str) and ip else None
- def _merge_ams_dict(prev_ams: dict, new_ams: dict) -> dict:
- """Merge a new ``ams`` blob from an incremental push onto the previous one.
- Bambu firmware sends three shapes for the ``ams`` field on push_status:
- 1. Full pushall (after a printer reconnect or explicit pushall request):
- ``{ams: [{id, tray: [{id, tray_type, ...}, ...]}, ...], ams_status, ams_exist_bits, ...}``
- — every unit + every tray populated.
- 2. Status-only incremental: ``{ams_status: 1}`` or ``{humidity: 30}`` —
- no ``ams`` array at all. Bambuddy logs these as "AMS partial update
- (no tray data)" (#784 vintage).
- 3. Tray-targeted incremental during a print: ``{ams: [{id: 0, tray:
- [{id: 0, state: 11}]}]}`` — only the units / trays whose state
- changed.
- Replacing the cached ``ams`` wholesale on shapes (2) and (3) is what
- made the slicer "lose" AMS between pushalls and trip the symptom in
- #1387: the slicer would see a stripped ``ams_status``-only blob and
- fall back to its "no AMS" default render. This merge mirrors the
- deep-merge logic in ``bambu_mqtt.py::_handle_ams_data`` at the bridge
- layer so the slicer-facing cache always carries the latest known
- coherent state.
- Strategy:
- - Shallow-merge top-level scalars: keys in ``new`` win; keys only
- in ``prev`` are preserved.
- - For the ``ams`` array (list of units): match by ``id``. Units
- only in ``prev`` survive. Units in ``new`` overlay onto their
- ``prev`` counterpart; same recursion applies to each unit's
- ``tray`` array by tray ``id``.
- """
- merged = dict(prev_ams)
- for k, v in new_ams.items():
- if k != "ams":
- merged[k] = v
- prev_units = prev_ams.get("ams") if isinstance(prev_ams.get("ams"), list) else []
- new_units = new_ams.get("ams") if isinstance(new_ams.get("ams"), list) else None
- if new_units is None:
- # Shape (2): no ``ams`` array in the incremental — keep prev's units.
- if prev_units:
- merged["ams"] = prev_units
- return merged
- prev_by_id = {u.get("id"): u for u in prev_units if isinstance(u, dict) and u.get("id") is not None}
- merged_units: list = []
- seen_ids: set = set()
- for new_unit in new_units:
- if not isinstance(new_unit, dict):
- merged_units.append(new_unit)
- continue
- uid = new_unit.get("id")
- prev_unit = prev_by_id.get(uid) if uid is not None else None
- if prev_unit is None:
- merged_units.append(new_unit)
- if uid is not None:
- seen_ids.add(uid)
- continue
- # Shallow-merge unit fields; preserve prev's trays not present in new.
- merged_unit = dict(prev_unit)
- for k, v in new_unit.items():
- if k != "tray":
- merged_unit[k] = v
- new_trays = new_unit.get("tray") if isinstance(new_unit.get("tray"), list) else None
- if new_trays is None:
- # Unit-level partial — keep prev's tray list intact.
- pass
- else:
- prev_trays = prev_unit.get("tray") if isinstance(prev_unit.get("tray"), list) else []
- prev_trays_by_id = {t.get("id"): t for t in prev_trays if isinstance(t, dict) and t.get("id") is not None}
- merged_trays: list = []
- seen_tray_ids: set = set()
- for new_tray in new_trays:
- if not isinstance(new_tray, dict):
- merged_trays.append(new_tray)
- continue
- tid = new_tray.get("id")
- prev_tray = prev_trays_by_id.get(tid) if tid is not None else None
- if prev_tray is None:
- merged_trays.append(new_tray)
- else:
- merged_tray = dict(prev_tray)
- merged_tray.update(new_tray)
- merged_trays.append(merged_tray)
- if tid is not None:
- seen_tray_ids.add(tid)
- # Preserve prev trays not mentioned in the incremental.
- for tid, prev_tray in prev_trays_by_id.items():
- if tid not in seen_tray_ids:
- merged_trays.append(prev_tray)
- merged_unit["tray"] = merged_trays
- merged_units.append(merged_unit)
- if uid is not None:
- seen_ids.add(uid)
- # Preserve prev units not mentioned in the incremental.
- for uid, prev_unit in prev_by_id.items():
- if uid not in seen_ids:
- merged_units.append(prev_unit)
- merged["ams"] = merged_units
- return merged
- class MQTTBridge:
- """Per-VP MQTT fan-out between a real printer and slicers connected to a VP."""
- def __init__(
- self,
- *,
- vp_id: int,
- vp_name: str,
- vp_serial: str,
- target_printer_id: int,
- mqtt_server: SimpleMQTTServer,
- printer_manager: PrinterManager,
- ):
- self.vp_id = vp_id
- self.vp_name = vp_name
- self.vp_serial = vp_serial
- self.target_printer_id = target_printer_id
- self._mqtt_server = mqtt_server
- self._printer_manager = printer_manager
- self._target_client: BambuMQTTClient | None = None
- self._target_serial: str | None = None
- self._target_ip_uint32_le: int | None = None
- self._vp_ip_uint32_le: int | None = None
- # Last reason `_refresh_ip_encoding` early-returned without arming.
- # Used to throttle the "NOT armed" diagnostic log to one line per
- # state change — refresh runs every 30s, so without throttling an
- # idle-but-unarmed bridge would emit one line per tick forever. Set
- # to None once arming succeeds so the next failure re-logs. #1429
- # follow-up: makes silent early-returns visible without grepping the
- # source.
- self._not_armed_reason: str | None = None
- self._loop: asyncio.AbstractEventLoop | None = None
- self._refresh_task: asyncio.Task | None = None
- self._stopping = False
- self._latest_print_state: dict | None = None
- self._latest_version_modules: list | None = None
- @property
- def is_active(self) -> bool:
- """True iff a target client is bound and currently connected."""
- client = self._target_client
- return bool(client is not None and getattr(client, "state", None) and client.state.connected)
- async def start(self) -> None:
- """Bind to the target printer (if connected) and start the refresh loop."""
- self._loop = asyncio.get_running_loop()
- self._stopping = False
- self._resolve_client()
- self._refresh_task = asyncio.create_task(self._refresh_loop())
- async def stop(self) -> None:
- """Detach from the target printer and stop the refresh loop."""
- self._stopping = True
- if self._refresh_task is not None:
- self._refresh_task.cancel()
- try:
- await self._refresh_task
- except asyncio.CancelledError:
- pass
- self._refresh_task = None
- self._unbind_client()
- self._loop = None
- async def _refresh_loop(self) -> None:
- """Re-resolve the target client periodically — paho clients can be replaced.
- BambuMQTTClient is destroyed and recreated on PrinterManager.connect_printer
- (e.g. printer config update). Without periodic refresh the bridge would lose
- fan-out after such a churn until the VP itself restarts.
- On crash exit, the handler must be unbound — otherwise the registered
- ``_on_printer_raw`` keeps firing on every real-printer message even
- though the bridge is functionally dead (memory leak + behaviour leak
- across VP restart).
- """
- try:
- while not self._stopping:
- await asyncio.sleep(REFRESH_INTERVAL_SECONDS)
- self._resolve_client()
- except asyncio.CancelledError:
- raise
- except Exception:
- logger.exception("[%s] MQTT bridge refresh loop crashed", self.vp_name)
- # Crash exit — unbind so the orphaned handler stops firing.
- # ``stop()`` won't be invoked because the task completes done-not-cancelled.
- self._unbind_client()
- def _resolve_client(self) -> None:
- """Look up the current client for target_printer_id and rebind if it changed."""
- try:
- current = self._printer_manager.get_client(self.target_printer_id)
- except Exception:
- logger.exception("[%s] MQTT bridge: get_client failed", self.vp_name)
- return
- if current is self._target_client:
- # Same client object — but `ip_address` can fill in *after* the
- # initial bind (e.g. DB row had a stale/empty value until the
- # client's first SSDP-driven IP refresh). The original code only
- # encoded `_target_ip_uint32_le` on client-identity change, so
- # that late-arriving IP was never picked up, the `net.info[*].ip`
- # rewrite stayed disabled, and the cache filled with the real
- # printer IP — #1429. Refresh the encoding every tick so it
- # self-heals once `ip_address` becomes valid.
- self._refresh_ip_encoding()
- return
- # Client identity changed — unregister from the old, register on the new.
- self._unbind_client()
- if current is None:
- return
- try:
- current.register_raw_message_handler(self._on_printer_raw)
- except Exception:
- logger.exception("[%s] MQTT bridge: register_raw_message_handler failed", self.vp_name)
- return
- self._target_client = current
- self._target_serial = getattr(current, "serial_number", None)
- self._refresh_ip_encoding()
- logger.info(
- "[%s] MQTT bridge bound to printer %s (serial=%s)",
- self.vp_name,
- self.target_printer_id,
- self._target_serial,
- )
- # Trigger a fresh get_version + pushall against the printer so the bridge
- # cache populates immediately. Bambuddy itself queries these on connect,
- # but that fires before the bridge attaches as a raw-message consumer,
- # so without this nudge the cache stays empty until the next periodic
- # query (which can be minutes away).
- #
- # The bind frequently races the real printer's MQTT TLS handshake — a
- # slicer-side reconnect re-resolves the client before the underlying
- # session has reconnected, especially on A1 firmware where the bridge
- # cycles more aggressively (#1721). When that happens, the nudge is a
- # no-op — the next periodic pushall populates the cache anyway — but
- # `request_status_update` logs WARNING on the not-connected return path
- # and pollutes every support bundle with a benign line.
- #
- # Gate both nudges on the client being actually connected. The fall-
- # through path is unchanged: when the client comes up, the next
- # `_resolve_client` tick re-enters this branch on identity change OR
- # the periodic pushall in `bambu_mqtt.py` fills the cache.
- client_connected = bool(getattr(getattr(current, "state", None), "connected", False))
- if not client_connected:
- logger.debug(
- "[%s] MQTT bridge: post-bind nudge skipped (printer client not connected yet)",
- self.vp_name,
- )
- else:
- request_fn = getattr(current, "_request_version", None)
- if callable(request_fn):
- try:
- request_fn()
- except Exception:
- logger.exception("[%s] MQTT bridge: _request_version failed", self.vp_name)
- request_status_fn = getattr(current, "request_status_update", None)
- if callable(request_status_fn):
- try:
- request_status_fn()
- except Exception:
- logger.exception("[%s] MQTT bridge: request_status_update failed", self.vp_name)
- def _unbind_client(self) -> None:
- if self._target_client is None:
- return
- try:
- self._target_client.unregister_raw_message_handler(self._on_printer_raw)
- except Exception:
- logger.exception("[%s] MQTT bridge: unregister_raw_message_handler failed", self.vp_name)
- logger.info("[%s] MQTT bridge unbound from printer %s", self.vp_name, self.target_printer_id)
- self._target_client = None
- self._target_serial = None
- def _refresh_ip_encoding(self) -> None:
- """(Re-)encode `_target_ip_uint32_le` / `_vp_ip_uint32_le` from current values.
- Called on every refresh tick, not just on client-identity change, so
- a late-arriving printer IP (or a bind-address change) is picked up
- without restarting the VP. When the encoding becomes valid for the
- first time *after* the cache already received a push with the real
- printer IP, also sweep the existing cache so the slicer's next pull
- sees the rewritten value (#1429). Without this sweep the sticky-key
- preservation keeps the poisoned `net.info[].ip` alive forever.
- VP bind IP resolution: when `mqtt_server.bind_address` is empty or
- `0.0.0.0` (the default for VPs that were never assigned a dedicated
- bind IP), fall back to auto-resolving the host interface in the same
- subnet as the printer's IP. Without this fallback, the rewrite never
- arms on a default-config flat-LAN install and `net.info[].ip` leaks
- the real printer IP — slicer follows it on Send (#1429 residual).
- """
- def _log_not_armed(reason: str) -> None:
- # Throttle: only log when the reason changes, otherwise an idle
- # unarmed bridge would emit one INFO line every refresh tick
- # (~30s) forever. Cleared on arm so a regression re-logs.
- if reason != self._not_armed_reason:
- logger.info("[%s] MQTT bridge IP encoding NOT armed: %s", self.vp_name, reason)
- self._not_armed_reason = reason
- client = self._target_client
- if client is None:
- _log_not_armed("target_client is None (bridge not bound to a printer)")
- return
- configured_target = getattr(client, "ip_address", None)
- if not configured_target:
- _log_not_armed("printer client has no ip_address yet")
- return
- # Printers configured by hostname/FQDN (e.g. `p1s.fritz.box`) need to
- # be resolved to an IPv4 before encoding: net.info[*].ip is uint32 LE
- # and can't carry a hostname (#1429 follow-up).
- target_ip = _resolve_target_to_ipv4(configured_target)
- if not target_ip:
- _log_not_armed(
- f"could not resolve printer host {configured_target!r} to IPv4 (invalid address and DNS lookup failed)"
- )
- return
- vp_ip = getattr(self._mqtt_server, "bind_address", None)
- vp_ip_source = "bind_address"
- if not vp_ip or vp_ip in ("0.0.0.0", ""): # nosec B104
- resolved = _resolve_host_interface_for_target(target_ip)
- if not resolved:
- _log_not_armed(
- f"no host interface shares a subnet with printer IP {target_ip} "
- "(and VP bind_address is 0.0.0.0/empty)"
- )
- return
- vp_ip = resolved
- vp_ip_source = "auto-resolved"
- try:
- new_target_le = _ip_to_uint32_le(target_ip)
- new_vp_le = _ip_to_uint32_le(vp_ip)
- except ValueError as e:
- _log_not_armed(f"invalid IPv4 (target={target_ip!r}, vp={vp_ip!r}): {e}")
- return
- if new_target_le == self._target_ip_uint32_le and new_vp_le == self._vp_ip_uint32_le:
- return # No change — nothing to do.
- # Encoding either became valid for the first time or shifted (DHCP
- # renewal, bind_ip reconfigured, etc.). Update + sweep the cache.
- was_armed = self._target_ip_uint32_le is not None and self._vp_ip_uint32_le is not None
- self._target_ip_uint32_le = new_target_le
- self._vp_ip_uint32_le = new_vp_le
- # Clear the dedup so a future failure re-emits the diagnostic line.
- self._not_armed_reason = None
- target_display = target_ip if target_ip == configured_target else f"{configured_target}→{target_ip}"
- logger.info(
- "[%s] MQTT bridge IP encoding %s: target=%s vp=%s (%s)",
- self.vp_name,
- "updated" if was_armed else "armed",
- target_display,
- vp_ip,
- vp_ip_source,
- )
- cached = self._latest_print_state
- if isinstance(cached, dict):
- n = self._rewrite_net_info_ips(cached)
- if n:
- logger.info(
- "[%s] MQTT bridge swept %d net.info[].ip entries in cached push",
- self.vp_name,
- n,
- )
- def _rewrite_net_info_ips(self, print_state: dict) -> int:
- """Rewrite every non-zero `net.info[].ip` in `print_state` to the VP bind IP.
- Returns the number of entries rewritten. Mutates `print_state` in place.
- Strategy: rewrite ALL entries with a non-zero `ip`, not only those
- matching `_target_ip_uint32_le`. Real printers (X1C, H2D Pro) can
- report multiple active interfaces (WiFi + Ethernet) with different
- IPs — only one matches the IP Bambuddy tracks, but the slicer may
- read any of them. Leaving non-matching entries pointing at real
- printer interfaces leaks an FTP fallback path that bypasses the VP
- (the #1429 / #1302 symptom). Entries with `ip == 0` are placeholders
- for unpopulated interfaces — leave them alone so the slicer's
- "active interface" detection still recognises them as absent.
- """
- if self._vp_ip_uint32_le is None:
- return 0
- net = print_state.get("net")
- if not isinstance(net, dict):
- return 0
- info = net.get("info")
- if not isinstance(info, list):
- return 0
- rewritten = 0
- for entry in info:
- if not isinstance(entry, dict):
- continue
- ip_value = entry.get("ip")
- if not isinstance(ip_value, int) or ip_value == 0:
- continue
- if ip_value == self._vp_ip_uint32_le:
- continue
- entry["ip"] = self._vp_ip_uint32_le
- rewritten += 1
- return rewritten
- def _on_printer_raw(self, topic: str, payload: bytes) -> None:
- """Paho-thread callback — cache the latest push_status for synthetic replay.
- Instead of fanning out a second stream of MQTT messages to the slicer
- (which trips BambuStudio's Send pre-flight consistency checks), we cache
- the latest real printer push_status here. The VP's existing 1 Hz
- synthetic push (which is what Send is built around) consults this cache
- and replaces its stub fields with real values when available.
- """
- if self._stopping:
- return
- target_serial = self._target_serial
- if not target_serial:
- return
- prefix = f"device/{target_serial}/"
- if not topic.startswith(prefix):
- return
- suffix = topic[len(prefix) :]
- if not suffix.startswith("report"):
- return
- try:
- data = json.loads(payload)
- except json.JSONDecodeError:
- return
- # Race-free by construction: `json.loads` returns a fresh dict tree per
- # call so paho-thread mutations below cannot collide with prior cached
- # state held by the asyncio thread. `_send_status_report`'s shallow
- # `dict(cached)` is also safe because nothing else writes to the cached
- # tree after assignment. The defensive deep-copy on store below removes
- # any future risk if a maintainer later re-enters the cached dict to
- # mutate it.
- # push_status snapshots → cache the print dict for the periodic 1 Hz
- # cached-as-base delivery. We do NOT fan these out separately (the
- # 1 Hz cached-as-base IS the slicer-facing push_status stream).
- print_data = data.get("print")
- if isinstance(print_data, dict) and print_data.get("command") == "push_status":
- for value in print_data.values():
- if isinstance(value, dict) and value.get("sn") == target_serial:
- value["sn"] = self.vp_serial
- # Note: `ipcam.rtsp_url` carries the real printer's IP. We pass it
- # through unchanged — the slicer uses it to fetch the live camera
- # stream directly from the printer. On the same LAN this works as
- # long as the slicer's stored access code matches the printer's
- # (i.e. configure the VP with the same access code as its target).
- # Rewrite real printer IP → VP bind IP in `net.info[*].ip` so the
- # slicer's FTP destination resolves to the VP, not the real printer.
- self._rewrite_net_info_ips(print_data)
- # Defensive deep copy on store so the cache is fully decoupled from
- # the freshly-parsed tree and from any reader's reference.
- new_state = copy.deepcopy(print_data)
- # Bambu firmware sends two kinds of push_status: full pushall
- # responses (on `pushall` requests / printer reconnect) which
- # include the full top-level field set (AMS, vt_tray, net,
- # cali_version, print_type, mc_print_stage, device, ...) — and
- # ~1 Hz incrementals with just the fields that changed (temps,
- # fan, wifi, status). Carry over every prev field the incoming
- # push doesn't overwrite, mirroring the per-field accumulate
- # pattern in bambu_mqtt.py's internal state handler — without
- # this the cache thins out to whatever the latest incremental
- # carried (~17 keys on P1S in #1622), and the slicer's Device-
- # tab capability gates (manage-calibration, AMS-assign dropdown,
- # …) flip off because their gating fields drained from the
- # cache. The deep-copy is defensive: without it the carried-
- # over nested dicts/lists are shared with the previous cache,
- # so any in-place mutation later would corrupt both.
- prev = self._latest_print_state
- if prev is not None:
- for prev_key, prev_value in prev.items():
- if prev_key not in new_state:
- new_state[prev_key] = copy.deepcopy(prev_value)
- # Firmware sends partial `ams` blobs (status-only / unit-
- # targeted / tray-targeted) under the same key on
- # incremental updates, which would overwrite the cached
- # full blob and break the slicer's AMS render (#1387 /
- # #1371). Deep-merge mirrors what bambu_mqtt.py does
- # internally in `_handle_ams_data`.
- if isinstance(new_state.get("ams"), dict) and isinstance(prev.get("ams"), dict):
- new_state["ams"] = _merge_ams_dict(prev["ams"], new_state["ams"])
- # Same per-field accumulate rule applied one level deeper for
- # other top-level dict-shaped fields. Firmware sends partial
- # `vt_tray` (external spool) updates right after a slicer
- # `ams_filament_setting` pick — typically just `{tray_info_idx,
- # tray_color}`, dropping the ~18 other fields (`tray_type`,
- # `state`, `remain`, `k`, `n`, `cali_idx`, `nozzle_temp_min/max`,
- # `tray_uuid`, `xcam_info`, ...) the slicer needs to render the
- # slot. Without overlay the next 1 Hz cached-as-base push
- # delivered the stripped dict and the slicer rendered the
- # external slot as "invalid" until a reload triggered a fresh
- # pushall (#1622 round 5, reported by @shaddowlink). AMS slots
- # didn't suffer because `_merge_ams_dict` deep-merges per tray.
- # Same shape covers `device`, `online`, `upgrade_state`, `ipcam`,
- # `upload`, `net`, ... against future firmware partials too.
- # `ams` is excluded — already deep-merged above.
- for key, new_value in list(new_state.items()):
- if key == "ams":
- continue
- prev_value = prev.get(key)
- if isinstance(prev_value, dict) and isinstance(new_value, dict):
- merged = dict(prev_value)
- merged.update(new_value)
- new_state[key] = merged
- # Apply empty-slot cleanup on the merged AMS so the slicer-facing
- # cache mirrors what Bambuddy's AMS card shows internally. Without
- # this the cached units carry stale per-tray filament fields for
- # slots whose `tray_exist_bits` bit is 0, and BambuStudio's Sync
- # paints those empty slots as phantom loaded filaments (#1726).
- # Runs whether or not a prev cache existed — fresh pushalls also
- # carry tray_exist_bits and benefit from the cleanup.
- # These units carry the RAW firmware ids — this cache is what the
- # slicer sees, and BambuStudio addresses the A2L's AMS-Lite as the
- # physical id 16 (it sends `ams_get_rfid {ams_id: 16}` through the
- # VP), so we must not normalise them to 6 the way Bambuddy's
- # internal state does. `apply_tray_exist_bits` folds 16 onto the
- # same bit base internally instead (#2697).
- merged_ams_dict = new_state.get("ams")
- if isinstance(merged_ams_dict, dict):
- units = merged_ams_dict.get("ams")
- apply_tray_exist_bits(
- units if isinstance(units, list) else [],
- merged_ams_dict.get("tray_exist_bits"),
- power_on_flag=merged_ams_dict.get("power_on_flag", True),
- log_label=self.vp_name,
- )
- self._latest_print_state = new_state
- dump_wire(self.vp_name, "in", new_state)
- return
- # info.get_version responses → cache the module list so the synthetic
- # version response can include the real AMS modules.
- info_data = data.get("info")
- if isinstance(info_data, dict) and info_data.get("command") == "get_version":
- modules = info_data.get("module")
- if isinstance(modules, list):
- rewritten: list = []
- for module in modules:
- if isinstance(module, dict):
- module = dict(module)
- if module.get("sn") == target_serial:
- module["sn"] = self.vp_serial
- rewritten.append(module)
- self._latest_version_modules = rewritten
- # Don't fan out get_version — the slicer's request (when it issues
- # one) is intercepted locally and answered from the cached modules.
- return
- # Everything else (extrusion_cali_get response, AMS write acks, xcam
- # responses, …): fan out to the slicer. These are responses to commands
- # the slicer (or Bambuddy) issued; the slicer matches by sequence_id and
- # ignores responses to commands it didn't send. Without this, slicer-
- # initiated queries like extrusion_cali_get hang forever and BambuStudio
- # blocks Send waiting for the response.
- loop = self._loop
- if loop is None:
- return
- target_bytes = target_serial.encode("ascii")
- if target_bytes in payload:
- payload = payload.replace(target_bytes, self.vp_serial.encode("ascii"))
- vp_topic = f"device/{self.vp_serial}/{suffix}"
- # Env-flagged command trace (#1622): every printer-originated response
- # that gets fanned to the slicer (extrusion_cali_get / ams write acks /
- # xcam / system / etc.) gets a line in vp_wire/<vp>_cmd.jsonl. Pair
- # with the slicer-side publishes captured in mqtt_server. Off by
- # default. Capture AFTER serial rewrite so the dump matches what the
- # slicer actually sees on the wire.
- append_event(self.vp_name, "printer_to_slicer", vp_topic, payload)
- try:
- asyncio.run_coroutine_threadsafe(
- self._mqtt_server.push_raw_to_clients(vp_topic, payload),
- loop,
- )
- except RuntimeError:
- pass
- def get_latest_print_state(self) -> dict | None:
- """Return the most recent real printer push_status `print` dict, or None."""
- return self._latest_print_state
- def get_latest_version_modules(self) -> list | None:
- """Return the most recent real printer get_version `module` list, or None."""
- return self._latest_version_modules
- def forward_to_printer(self, payload: dict) -> bool:
- """Publish a slicer-originated command to the real printer's request topic.
- Returns False if no printer client is currently bound.
- """
- client = self._target_client
- target_serial = self._target_serial
- if client is None or target_serial is None:
- logger.debug(
- "[%s] forward_to_printer dropped (printer %s not bound): %s",
- self.vp_name,
- self.target_printer_id,
- list(payload.keys()),
- )
- return False
- topic = f"device/{target_serial}/request"
- try:
- return client.publish_raw(topic, json.dumps(payload), qos=1)
- except Exception:
- logger.exception("[%s] forward_to_printer publish failed", self.vp_name)
- return False
|