| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635 |
- """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 json
- import logging
- from typing import TYPE_CHECKING
- 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
- # Top-level push_status fields that Bambu firmware sends in FULL pushall
- # responses (on `pushall` request / printer reconnect) but typically OMITS
- # from 1 Hz incremental push_status updates. Without preserving these
- # fields across incremental updates, the bridge cache would lose AMS info
- # (and friends) between pushalls — slicers reading the cache would see a
- # stripped-down state and the fix would only re-appear on a manual printer
- # power-cycle (#1371). Mirrors the same set Bambuddy itself preserves in
- # bambu_mqtt.py:2686-2711 for its own internal raw_data, with a few more
- # entries that the slicer cares about (net, ipcam, lights_report).
- _SLICER_VISIBLE_STICKY_KEYS: tuple[str, ...] = (
- "ams",
- "vt_tray",
- "ams_extruder_map",
- "mapping",
- "net",
- "ipcam",
- "lights_report",
- # Pre-flight / Prepare-tab fields that BambuStudio reads off cached
- # push_status. Bambu firmware emits them in full pushall but typically
- # OMITS them from 1 Hz incremental updates, so without sticky-preservation
- # the cache drops them after the very next tick and the slicer's
- # "block Send while busy / unknown firmware" branch kicks in. Same shape
- # as #1228 (storage indicators) and #1558 (live-progress fields) —
- # cached-branch field-shape parity, not a new mechanism.
- "upgrade_state", # Send pre-flight reads dis_state / force_upgrade
- "xcam", # Prepare-tab reads spaghetti / first-layer / halt sensitivity
- "hw_switch_state", # Hardware switch state (Prepare tab)
- "nozzle_diameter",
- "nozzle_type",
- "online", # Module online map (ahb / rfid / version)
- "ams_status", # AMS overall status; can be ams_status-only incremental
- )
- 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_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
- 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).
- 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).
- """
- client = self._target_client
- if client is None:
- return
- target_ip = getattr(client, "ip_address", None)
- if not target_ip:
- 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:
- 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:
- 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
- logger.info(
- "[%s] MQTT bridge IP encoding %s: target=%s vp=%s (%s)",
- self.vp_name,
- "updated" if was_armed else "armed",
- target_ip,
- 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 AMS, vt_tray, net, etc. — and ~1 Hz incremental
- # updates with just the fields that changed (typically temps,
- # fan, wifi). Without preserving sticky fields from the previous
- # cache, the first incremental push after a pushall would wipe
- # AMS info from the bridge cache, and slicers reading the cache
- # between pushalls would see a stripped-down printer state with
- # no AMS visible until the next pushall — typically only when
- # the user power-cycles the printer (#1371). Mirror the same
- # preservation pattern Bambuddy uses for its own internal state
- # in bambu_mqtt.py (see _SLICER_VISIBLE_STICKY_KEYS below).
- prev = self._latest_print_state
- if prev is not None:
- for sticky_key in _SLICER_VISIBLE_STICKY_KEYS:
- if sticky_key not in new_state:
- if sticky_key in prev:
- # Defensive deep copy — without this the carried-over
- # nested dicts/lists are shared between new_state and
- # the previous cache, so any in-place mutation later
- # (current or future code paths) would corrupt both.
- new_state[sticky_key] = copy.deepcopy(prev[sticky_key])
- continue
- # Key IS in new_state — but firmware sends partial blobs
- # (status-only / tray-targeted) under the same key on
- # incremental updates, which would overwrite the cached
- # full blob and break the slicer's AMS render (#1387).
- # For `ams` specifically the deep-merge mirrors what
- # Bambuddy already does internally in `_handle_ams_data`.
- if (
- sticky_key == "ams"
- and isinstance(new_state.get("ams"), dict)
- and isinstance(prev.get("ams"), dict)
- ):
- new_state["ams"] = _merge_ams_dict(prev["ams"], new_state["ams"])
- self._latest_print_state = 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}"
- 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
|