|
@@ -22,6 +22,8 @@ from datetime import datetime, timezone
|
|
|
import paho.mqtt.client as mqtt
|
|
import paho.mqtt.client as mqtt
|
|
|
|
|
|
|
|
from backend.app.services.hms_actions import HMSAction, get_actions_for_error_code
|
|
from backend.app.services.hms_actions import HMSAction, get_actions_for_error_code
|
|
|
|
|
+from backend.app.services.hms_errors import describe_fault
|
|
|
|
|
+from backend.app.utils.ams_drying import ACTIVE_DRY_STATUSES
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
@@ -40,12 +42,6 @@ _AMS_MODULE_PREFIXES = ("ams/", "n3f/", "n3s/")
|
|
|
# printer_manager.ACTIVE_PRINT_STATES and print_scheduler._ACTIVE_PRINT_STATES.
|
|
# printer_manager.ACTIVE_PRINT_STATES and print_scheduler._ACTIVE_PRINT_STATES.
|
|
|
_ACTIVE_PRINT_STATES = frozenset({"PREPARE", "SLICING", "RUNNING", "PAUSE"})
|
|
_ACTIVE_PRINT_STATES = frozenset({"PREPARE", "SLICING", "RUNNING", "PAUSE"})
|
|
|
|
|
|
|
|
-# AMS dry_status phases (info bits 4-7) in which a drying cycle is still live, so
|
|
|
|
|
-# a dry_time of 0 alongside one of them is a transient rather than a completion
|
|
|
|
|
-# (#2759). 0=Off, 4=Stopping and 5=Error all mean the cycle is over or ending and
|
|
|
|
|
-# are deliberately excluded — those SHOULD end it.
|
|
|
|
|
-_ACTIVE_DRY_STATUSES = frozenset({1, 2, 3}) # Checking, Drying, Cooling
|
|
|
|
|
-
|
|
|
|
|
# A drying cycle that runs to term ends with its countdown all but exhausted, so
|
|
# A drying cycle that runs to term ends with its countdown all but exhausted, so
|
|
|
# the last dry_time we saw before the drop to 0 tells us whether the firmware
|
|
# the last dry_time we saw before the drop to 0 tells us whether the firmware
|
|
|
# ended the cycle on schedule or aborted it. More than this many minutes still on
|
|
# ended the cycle on schedule or aborted it. More than this many minutes still on
|
|
@@ -115,6 +111,34 @@ def normalize_am_unit_id(ams_id: int) -> int:
|
|
|
return A2L_LITE_NORMALIZED_AMS_ID if ams_id == A2L_LITE_PHYSICAL_AMS_ID else ams_id
|
|
return A2L_LITE_NORMALIZED_AMS_ID if ams_id == A2L_LITE_PHYSICAL_AMS_ID else ams_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def wire_tray_color(tray_color: str | None) -> str:
|
|
|
|
|
+ """Normalise a colour to the form AMS firmware actually parses: UPPERCASE hex.
|
|
|
|
|
+
|
|
|
|
|
+ P1S firmware 01.10.00.00 parses every lowercase hex letter in ``tray_color``
|
|
|
|
|
+ as a zero, and does it silently: the command response echoes the value you
|
|
|
|
|
+ sent and reports ``result: "success"``, so only the next AMS push shows what
|
|
|
|
|
+ was really stored. Measured on the reporter's machine (#2987), where the
|
|
|
|
|
+ spool's own ``rgba`` is stored lowercase and went out verbatim:
|
|
|
|
|
+
|
|
|
|
|
+ sent 09ff00ff -> AMS reports 09000000
|
|
|
|
|
+ sent ff5100ff -> AMS reports 00510000
|
|
|
|
|
+ sent 090000FF -> AMS reports 090000FF
|
|
|
|
|
+
|
|
|
|
|
+ A mangled colour is not merely cosmetic. The auto-unlink sweep compares the
|
|
|
|
|
+ tray against the spool it is assigned to, so the tray Bambuddy just wrote no
|
|
|
|
|
+ longer matches the spool that asked for it and the assignment is deleted
|
|
|
|
|
+ seconds after being made -- and re-assigning through the slot modal writes
|
|
|
|
|
+ the mangled colour back, because the modal seeds itself from the tray.
|
|
|
|
|
+
|
|
|
|
|
+ Applied here, at the one place the command is built, rather than in each of
|
|
|
|
|
+ the four callers: a caller that forgets is exactly how this arrived.
|
|
|
|
|
+
|
|
|
|
|
+ A leading ``#`` is stripped -- the wire format carries bare hex -- and a
|
|
|
|
|
+ blank stays blank, which is how a slot is cleared.
|
|
|
|
|
+ """
|
|
|
|
|
+ return (tray_color or "").strip().lstrip("#").upper()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def a2l_lite_wire_ids(ams_id: int, tray_id: int) -> tuple[int, int, int] | None:
|
|
def a2l_lite_wire_ids(ams_id: int, tray_id: int) -> tuple[int, int, int] | None:
|
|
|
"""Translate a normalised A2L slot back to the physical wire form.
|
|
"""Translate a normalised A2L slot back to the physical wire form.
|
|
|
|
|
|
|
@@ -630,7 +654,13 @@ class HMSError:
|
|
|
attr: int # Attribute value for constructing wiki URL
|
|
attr: int # Attribute value for constructing wiki URL
|
|
|
module: int
|
|
module: int
|
|
|
severity: int # 1=fatal, 2=serious, 3=common, 4=info
|
|
severity: int # 1=fatal, 2=serious, 3=common, 4=info
|
|
|
- message: str = ""
|
|
|
|
|
|
|
+ # The bundled catalogue's sentence for this fault, resolved once here so
|
|
|
|
|
+ # every surface that reports it — the status response, the WebSocket
|
|
|
|
|
+ # broadcast, the completion payload, notifications — says the same thing.
|
|
|
|
|
+ # None when the catalogue does not cover the code; `describe_fault` documents
|
|
|
|
|
+ # the lookup and why the lossy `hms[]` collapse is kept as it was.
|
|
|
|
|
+ # Replaces a `message` field that was never set or read anywhere.
|
|
|
|
|
+ description: str | None = None
|
|
|
# User-facing remediation actions from the bundled HMS catalog (e.g. "RESUME_PRINTING",
|
|
# User-facing remediation actions from the bundled HMS catalog (e.g. "RESUME_PRINTING",
|
|
|
# "CHECK_ASSISTANT"). Defaults to an empty list rather than None so the field always
|
|
# "CHECK_ASSISTANT"). Defaults to an empty list rather than None so the field always
|
|
|
# satisfies HMSErrorResponse.actions: list[str] — a future code path that builds an
|
|
# satisfies HMSErrorResponse.actions: list[str] — a future code path that builds an
|
|
@@ -708,16 +738,74 @@ class FilaSwitchState:
|
|
|
AMS and the printer's extruders. When installed, the AMS no longer has a
|
|
AMS and the printer's extruders. When installed, the AMS no longer has a
|
|
|
fixed extruder assignment — any slot can be routed to any extruder via the
|
|
fixed extruder assignment — any slot can be routed to any extruder via the
|
|
|
track switch. Detected from print.device.fila_switch in MQTT.
|
|
track switch. Detected from print.device.fila_switch in MQTT.
|
|
|
|
|
+
|
|
|
|
|
+ The switch has two inlets (In-A, In-B) and two outlets (Out-A, Out-B), and
|
|
|
|
|
+ can pair any inlet with any outlet. Which AMS sits on which *inlet* is the
|
|
|
|
|
+ stable, operator-visible relationship — it is set on the printer's "Manual
|
|
|
|
|
+ AMS Setup" screen and read back from AMS ``info`` bits 24-27, not from here.
|
|
|
|
|
+
|
|
|
|
|
+ Field semantics below are taken from BambuStudio's own parser
|
|
|
|
|
+ (``DevFilaSwitch::ParseFilaSwitchInfo``), not inferred.
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
|
installed: bool = False
|
|
installed: bool = False
|
|
|
- # in[track] = currently loaded slot for that track (-1 = empty). The slot
|
|
|
|
|
- # value is reported as observed in MQTT (treated as a global tray ID).
|
|
|
|
|
|
|
+ # Raw ``in`` array, as it arrives. **Index 0 is In-B and index 1 is In-A** —
|
|
|
|
|
+ # the arrays are ordered B-then-A, which is the opposite of how they read.
|
|
|
|
|
+ # Each value is snow-encoded: bits 8-15 = AMS id, bits 0-7 = slot. -1 = the
|
|
|
|
|
+ # inlet is empty. Use `inlet_slot()` rather than indexing this directly.
|
|
|
in_slots: list[int] = field(default_factory=list)
|
|
in_slots: list[int] = field(default_factory=list)
|
|
|
- # out[track] = extruder this track terminates at (0 = right/main, 1 = left)
|
|
|
|
|
|
|
+ # Raw ``out`` array, same B-then-A order. out[i] = the extruder that *outlet*
|
|
|
|
|
+ # terminates at (0 = right/main, 1 = left/deputy), or 0xE when unset. Note
|
|
|
|
|
+ # this is the outlet's static wiring, NOT the live inlet→outlet route: which
|
|
|
|
|
+ # inlet is currently paired with which outlet is not reported at all.
|
|
|
out_extruders: list[int] = field(default_factory=list)
|
|
out_extruders: list[int] = field(default_factory=list)
|
|
|
- stat: int = 0 # status flags (0 = idle)
|
|
|
|
|
- info: int = 0 # info flags
|
|
|
|
|
|
|
+ stat: int = 0 # CaliStatus: 0 = idle, 1 = calibration stepping
|
|
|
|
|
+ info: int = 0 # bit 0 = inlet has filament
|
|
|
|
|
+
|
|
|
|
|
+ def inlet_slot(self, inlet: str) -> tuple[int, int] | None:
|
|
|
|
|
+ """Decode ``in`` for inlet ``"A"`` or ``"B"`` into ``(ams_id, slot)``.
|
|
|
|
|
+
|
|
|
|
|
+ Returns None when the inlet is empty, unreported, or ``inlet`` is not
|
|
|
|
|
+ one of A/B.
|
|
|
|
|
+ """
|
|
|
|
|
+ index = {"A": 1, "B": 0}.get(inlet.upper())
|
|
|
|
|
+ if index is None or index >= len(self.in_slots):
|
|
|
|
|
+ return None
|
|
|
|
|
+ raw = self.in_slots[index]
|
|
|
|
|
+ if raw < 0:
|
|
|
|
|
+ return None
|
|
|
|
|
+ return (raw >> 8) & 0xFF, raw & 0xFF
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ``snow``/``spre``/``star`` all use this sentinel for "nothing here". Studio
|
|
|
|
|
+# only special-cases it on single-extruder machines, but 0xFFFF decodes to AMS
|
|
|
|
|
+# 255 slot 255 and slot 255 is not a real slot on any machine, so treating it
|
|
|
|
|
+# as empty everywhere is strictly safer than reading it as the external spool.
|
|
|
|
|
+_EXTRUDER_SLOT_EMPTY = 0xFFFF
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@dataclass
|
|
|
|
|
+class ExtruderSlot:
|
|
|
|
|
+ """Which AMS slot an extruder is currently fed from.
|
|
|
|
|
+
|
|
|
|
|
+ Parsed from ``print.device.extruder.info[i]`` — ``snow`` is snow-encoded
|
|
|
|
|
+ exactly like ``fila_switch.in`` (bits 8-15 = AMS id, bits 0-7 = slot), and
|
|
|
|
|
+ bit 1 of ``info`` says whether the extruder actually holds filament. Field
|
|
|
|
|
+ semantics from BambuStudio's ``DevExtruderSystem::ParseExtruderInfo``.
|
|
|
|
|
+
|
|
|
|
|
+ ``state.tray_now`` cannot answer this: it is a single value for the whole
|
|
|
|
|
+ printer, so on a dual-nozzle machine with both hotends loaded it names only
|
|
|
|
|
+ one of them. Unloading a specific slot needs to know which extruder is
|
|
|
|
|
+ holding it, which is what this is for.
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ ams_id: int | None = None
|
|
|
|
|
+ slot_id: int | None = None
|
|
|
|
|
+ has_filament: bool = False
|
|
|
|
|
+
|
|
|
|
|
+ def holds(self, ams_id: int, slot_id: int) -> bool:
|
|
|
|
|
+ """True when this extruder is fed from exactly ``(ams_id, slot_id)``."""
|
|
|
|
|
+ return self.ams_id == ams_id and self.slot_id == slot_id
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
@dataclass
|
|
@@ -793,7 +881,12 @@ class PrinterState:
|
|
|
wifi_signal: int | None = None # WiFi signal strength in dBm
|
|
wifi_signal: int | None = None # WiFi signal strength in dBm
|
|
|
wired_network: bool = False # Ethernet connection detected (home_flag bit 18)
|
|
wired_network: bool = False # Ethernet connection detected (home_flag bit 18)
|
|
|
door_open: bool = False # Enclosure door open (home_flag bit 23; models with a door sensor: X1/X1C/X1E/X2D/P2S/H2*)
|
|
door_open: bool = False # Enclosure door open (home_flag bit 23; models with a door sensor: X1/X1C/X1E/X2D/P2S/H2*)
|
|
|
- # Nozzle hardware info (for dual nozzle printers, index 0 = left, 1 = right)
|
|
|
|
|
|
|
+ # Nozzle hardware info. Indexed by EXTRUDER id: [0] is the RIGHT hotend and
|
|
|
|
|
+ # [1] the left, measured 2026-08-27 on an H2D fitted with 0.4 left / 0.6
|
|
|
|
|
+ # right. (The legacy parser below writes left -> [0], but it only ever runs
|
|
|
|
|
+ # for single-nozzle printers -- every dual-nozzle model reports
|
|
|
|
|
+ # device.nozzle.info instead.) Read it through services.slot_nozzle rather
|
|
|
|
|
+ # than indexing it directly.
|
|
|
nozzles: list = field(default_factory=lambda: [NozzleInfo(), NozzleInfo()])
|
|
nozzles: list = field(default_factory=lambda: [NozzleInfo(), NozzleInfo()])
|
|
|
# AI detection and print options
|
|
# AI detection and print options
|
|
|
print_options: PrintOptions = field(default_factory=PrintOptions)
|
|
print_options: PrintOptions = field(default_factory=PrintOptions)
|
|
@@ -839,6 +932,16 @@ class PrinterState:
|
|
|
# Filament Track Switch (FTS) accessory — when installed, AMS info reports
|
|
# Filament Track Switch (FTS) accessory — when installed, AMS info reports
|
|
|
# bits 8-11 = 0xE (uninitialized) because routing is dynamic. See #1162.
|
|
# bits 8-11 = 0xE (uninitialized) because routing is dynamic. See #1162.
|
|
|
fila_switch: "FilaSwitchState" = field(default_factory=lambda: FilaSwitchState())
|
|
fila_switch: "FilaSwitchState" = field(default_factory=lambda: FilaSwitchState())
|
|
|
|
|
+ # Per-AMS FTS inlet binding: {ams_id: "A" | "B"}. Which of the switch's two
|
|
|
|
|
+ # filament inlets an AMS is plumbed into, as set on the printer's "Manual AMS
|
|
|
|
|
+ # Setup" screen. Only populated when an FTS is installed — without one an AMS
|
|
|
|
|
+ # is bound to an extruder instead and this stays empty. See FilaSwitchState.
|
|
|
|
|
+ ams_switch_inlet: dict = field(default_factory=dict)
|
|
|
|
|
+ # Which AMS slot each extruder is fed from: {extruder_id: ExtruderSlot}.
|
|
|
|
|
+ # Only populated by printers that report ``device.extruder.info`` (H2/X2
|
|
|
|
|
+ # series). Empty elsewhere, which every reader has to tolerate — see
|
|
|
|
|
+ # ExtruderSlot for why tray_now cannot stand in for it.
|
|
|
|
|
+ extruder_slots: dict = field(default_factory=dict)
|
|
|
# Plate dispatched by Bambuddy for the current print. Some firmware versions
|
|
# Plate dispatched by Bambuddy for the current print. Some firmware versions
|
|
|
# (P1S 01.10.00.00) only put the .3mf filename in print.gcode_file, so the
|
|
# (P1S 01.10.00.00) only put the .3mf filename in print.gcode_file, so the
|
|
|
# regex used to derive the plate number from the path always falls back to
|
|
# regex used to derive the plate number from the path always falls back to
|
|
@@ -1051,6 +1154,17 @@ class BambuMQTTClient:
|
|
|
# Class-level cache: serial_number -> False when request topic is known unsupported.
|
|
# Class-level cache: serial_number -> False when request topic is known unsupported.
|
|
|
# Persists across client instances so reconnects don't re-trigger failed subscriptions.
|
|
# Persists across client instances so reconnects don't re-trigger failed subscriptions.
|
|
|
_request_topic_cache: dict[str, bool] = {}
|
|
_request_topic_cache: dict[str, bool] = {}
|
|
|
|
|
+ # serial_number -> consecutive disconnects seen shortly after subscribing to
|
|
|
|
|
+ # the request topic. A SUBACK failure is the broker answering the question;
|
|
|
|
|
+ # a disconnect is only circumstantial, and any drop inside the window looks
|
|
|
|
|
+ # identical -- a network blip, the printer rebooting, the container being
|
|
|
|
|
+ # stopped mid-probe. Latching on the first one costs ams_mapping capture for
|
|
|
|
|
+ # the rest of the process on a printer that supports it perfectly well
|
|
|
|
|
+ # (#2953). Require the drop to repeat before believing it; a printer that
|
|
|
|
|
+ # really does refuse the topic answers the same way every time and pays one
|
|
|
|
|
+ # extra reconnect for it.
|
|
|
|
|
+ _request_topic_probe_failures: dict[str, int] = {}
|
|
|
|
|
+ _REQUEST_TOPIC_PROBE_LIMIT: int = 2
|
|
|
# Counter for generating unique MQTT client IDs across instances.
|
|
# Counter for generating unique MQTT client IDs across instances.
|
|
|
_client_instance_counter: int = 0
|
|
_client_instance_counter: int = 0
|
|
|
|
|
|
|
@@ -1078,6 +1192,7 @@ class BambuMQTTClient:
|
|
|
on_finish_photo_moment: Callable[[dict], None] | None = None,
|
|
on_finish_photo_moment: Callable[[dict], None] | None = None,
|
|
|
on_assignment_verified: Callable[[int, int, bool, dict], None] | None = None,
|
|
on_assignment_verified: Callable[[int, int, bool, dict], None] | None = None,
|
|
|
on_tray_change: Callable[[int, int], None] | None = None,
|
|
on_tray_change: Callable[[int, int], None] | None = None,
|
|
|
|
|
+ on_fts_inlet_change: Callable[[int, str], None] | None = None,
|
|
|
):
|
|
):
|
|
|
self.ip_address = ip_address
|
|
self.ip_address = ip_address
|
|
|
self.serial_number = serial_number
|
|
self.serial_number = serial_number
|
|
@@ -1089,6 +1204,9 @@ class BambuMQTTClient:
|
|
|
self.on_print_start = on_print_start
|
|
self.on_print_start = on_print_start
|
|
|
self.on_print_complete = on_print_complete
|
|
self.on_print_complete = on_print_complete
|
|
|
self.on_ams_change = on_ams_change
|
|
self.on_ams_change = on_ams_change
|
|
|
|
|
+ # Fired when an AMS is moved to the switch's other inlet, which changes
|
|
|
|
|
+ # the nozzle it feeds and so invalidates its slots' K-profile bindings.
|
|
|
|
|
+ self.on_fts_inlet_change = on_fts_inlet_change
|
|
|
self.on_layer_change = on_layer_change
|
|
self.on_layer_change = on_layer_change
|
|
|
# #2547: fired when `mc_percent` advances during a running print.
|
|
# #2547: fired when `mc_percent` advances during a running print.
|
|
|
# `on_layer_change` stops firing the instant the final layer starts, so
|
|
# `on_layer_change` stops firing the instant the final layer starts, so
|
|
@@ -1230,12 +1348,35 @@ class BambuMQTTClient:
|
|
|
# Value: {"nozzle": str, "event": asyncio.Event, "profiles": list | None}.
|
|
# Value: {"nozzle": str, "event": asyncio.Event, "profiles": list | None}.
|
|
|
self._sequence_id: int = 0
|
|
self._sequence_id: int = 0
|
|
|
self._pending_kprofile_requests: dict[str, dict] = {}
|
|
self._pending_kprofile_requests: dict[str, dict] = {}
|
|
|
|
|
+ # The printer's calibration table, one bucket per nozzle diameter.
|
|
|
|
|
+ #
|
|
|
|
|
+ # An extrusion_cali_get response is the complete table for *one* nozzle
|
|
|
|
|
+ # size, and the printer answers whoever asks — including BambuStudio,
|
|
|
|
|
+ # whose queries land on the same report topic we subscribe to. Assigning
|
|
|
|
|
+ # each response straight to state.kprofiles therefore let any single
|
|
|
|
|
+ # answer stand for the whole printer: a GitHub backup probing
|
|
|
|
|
+ # 0.2/0.4/0.6/0.8 in turn finished on 0.8, which holds no profiles on a
|
|
|
|
|
+ # 0.4+0.6 machine, and left the list empty until something refilled it.
|
|
|
|
|
+ # Measured on the maintainer's H2 on 2026-08-25, and visible on the AMS
|
|
|
|
|
+ # card because H2-series trays carry no `k` of their own — the slot's
|
|
|
|
|
+ # K value is resolved from cali_idx against exactly this list.
|
|
|
|
|
+ #
|
|
|
|
|
+ # Keyed by diameter so a response only ever replaces the bucket it
|
|
|
|
|
+ # actually describes; state.kprofiles is then the union across buckets.
|
|
|
|
|
+ # An empty answer for a nozzle the printer doesn't have empties that
|
|
|
|
|
+ # bucket alone.
|
|
|
|
|
+ self._kprofiles_by_nozzle: dict[str, list] = {}
|
|
|
# Acks for K-profile *writes* (extrusion_cali_set / extrusion_cali_del),
|
|
# Acks for K-profile *writes* (extrusion_cali_set / extrusion_cali_del),
|
|
|
# keyed by the sequence_id we sent. The printer echoes it back, measured
|
|
# keyed by the sequence_id we sent. The printer echoes it back, measured
|
|
|
# on both an X1C and an H2D (#2718). Filled by the MQTT thread, drained
|
|
# on both an X1C and an H2D (#2718). Filled by the MQTT thread, drained
|
|
|
# by await_cali_ack.
|
|
# by await_cali_ack.
|
|
|
self._pending_cali_acks: dict[str, dict | None] = {}
|
|
self._pending_cali_acks: dict[str, dict | None] = {}
|
|
|
|
|
|
|
|
|
|
+ # Identifies the one project_file *we* dispatched, so its echo on the
|
|
|
|
|
+ # topic can be told apart from a slicer's. One-shot: consumed by the
|
|
|
|
|
+ # first frame that matches. See _project_file_key.
|
|
|
|
|
+ self._own_project_file_key: str | None = None
|
|
|
|
|
+
|
|
|
# Xcam hold timers - OrcaSlicer pattern: ignore incoming data for 3 seconds after command
|
|
# Xcam hold timers - OrcaSlicer pattern: ignore incoming data for 3 seconds after command
|
|
|
# Key: module_name, Value: timestamp when command was sent
|
|
# Key: module_name, Value: timestamp when command was sent
|
|
|
self._xcam_hold_start: dict[str, float] = {}
|
|
self._xcam_hold_start: dict[str, float] = {}
|
|
@@ -1655,6 +1796,7 @@ class BambuMQTTClient:
|
|
|
)
|
|
)
|
|
|
self._request_topic_confirmed = True
|
|
self._request_topic_confirmed = True
|
|
|
BambuMQTTClient._request_topic_cache[self.serial_number] = True
|
|
BambuMQTTClient._request_topic_cache[self.serial_number] = True
|
|
|
|
|
+ BambuMQTTClient._request_topic_probe_failures.pop(self.serial_number, None)
|
|
|
self._request_topic_sub_mid = None
|
|
self._request_topic_sub_mid = None
|
|
|
self._request_topic_sub_time = 0.0
|
|
self._request_topic_sub_time = 0.0
|
|
|
|
|
|
|
@@ -1711,13 +1853,30 @@ class BambuMQTTClient:
|
|
|
self._request_topic_sub_time > 0
|
|
self._request_topic_sub_time > 0
|
|
|
and not self._request_topic_confirmed
|
|
and not self._request_topic_confirmed
|
|
|
and time.time() - self._request_topic_sub_time < 10.0
|
|
and time.time() - self._request_topic_sub_time < 10.0
|
|
|
|
|
+ # A disconnect we asked for says nothing about the subscription.
|
|
|
|
|
+ and self._disconnection_event is None
|
|
|
):
|
|
):
|
|
|
- logger.warning(
|
|
|
|
|
- "[%s] Disconnected shortly after request topic subscription. Disabling request topic for this printer.",
|
|
|
|
|
- self.serial_number,
|
|
|
|
|
- )
|
|
|
|
|
- self._request_topic_supported = False
|
|
|
|
|
- BambuMQTTClient._request_topic_cache[self.serial_number] = False
|
|
|
|
|
|
|
+ failures = BambuMQTTClient._request_topic_probe_failures.get(self.serial_number, 0) + 1
|
|
|
|
|
+ BambuMQTTClient._request_topic_probe_failures[self.serial_number] = failures
|
|
|
|
|
+ if failures >= BambuMQTTClient._REQUEST_TOPIC_PROBE_LIMIT:
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "[%s] Disconnected shortly after request topic subscription %d times. "
|
|
|
|
|
+ "Disabling request topic for this printer — ams_mapping capture from "
|
|
|
|
|
+ "slicer-initiated prints is unavailable, and their filament will be "
|
|
|
|
|
+ "attributed from the printer's own tray reporting instead.",
|
|
|
|
|
+ self.serial_number,
|
|
|
|
|
+ failures,
|
|
|
|
|
+ )
|
|
|
|
|
+ self._request_topic_supported = False
|
|
|
|
|
+ BambuMQTTClient._request_topic_cache[self.serial_number] = False
|
|
|
|
|
+ else:
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "[%s] Disconnected shortly after request topic subscription (%d/%d). "
|
|
|
|
|
+ "Retrying it on the next connection before giving up.",
|
|
|
|
|
+ self.serial_number,
|
|
|
|
|
+ failures,
|
|
|
|
|
+ BambuMQTTClient._REQUEST_TOPIC_PROBE_LIMIT,
|
|
|
|
|
+ )
|
|
|
self._request_topic_sub_mid = None
|
|
self._request_topic_sub_mid = None
|
|
|
self._request_topic_sub_time = 0.0
|
|
self._request_topic_sub_time = 0.0
|
|
|
|
|
|
|
@@ -1826,16 +1985,94 @@ class BambuMQTTClient:
|
|
|
# Diagnostic for #1162 follow-up (X2D + FTS routing): when a
|
|
# Diagnostic for #1162 follow-up (X2D + FTS routing): when a
|
|
|
# slicer-launched project_file passes through the request topic,
|
|
# slicer-launched project_file passes through the request topic,
|
|
|
# log the full payload so we can diff Studio's field set against
|
|
# log the full payload so we can diff Studio's field set against
|
|
|
- # ours. We pin our own sequence_id to "20000" (line ~3195), so
|
|
|
|
|
- # any other value means the command came from Studio/Orca, not
|
|
|
|
|
- # from us.
|
|
|
|
|
- if print_data.get("sequence_id") != "20000":
|
|
|
|
|
|
|
+ # ours.
|
|
|
|
|
+ #
|
|
|
|
|
+ # This used to read `sequence_id != "20000"`, on the belief that
|
|
|
|
|
+ # 20000 was ours alone. It is not: 20000 is the slicer convention
|
|
|
|
|
+ # Bambuddy adopted -- bind_server documents the slicer sending it
|
|
|
|
|
+ # during detect, and measured on the wire OrcaSlicer dispatched
|
|
|
|
|
+ # 20000 then 20001 while BambuStudio was on 20009/20010, both
|
|
|
|
|
+ # counting up from the same base. So the test swallowed whichever
|
|
|
|
|
+ # slicer dispatch happened to land on 20000, which on a fresh
|
|
|
|
|
+ # slicer start is the first one. Match our own dispatch instead.
|
|
|
|
|
+ if self._project_file_key(print_data) == self._own_project_file_key:
|
|
|
|
|
+ self._own_project_file_key = None
|
|
|
|
|
+ else:
|
|
|
logger.info(
|
|
logger.info(
|
|
|
"[%s] External project_file payload: %s",
|
|
"[%s] External project_file payload: %s",
|
|
|
self.serial_number,
|
|
self.serial_number,
|
|
|
json.dumps(print_data),
|
|
json.dumps(print_data),
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
+ def _capture_report_project_file(self, print_data: dict) -> None:
|
|
|
|
|
+ """Read a print's destination off a ``project_file`` *response* (#1820).
|
|
|
|
|
+
|
|
|
|
|
+ ``_handle_request_message`` only ever sees the request topic, so a print
|
|
|
|
|
+ started from the printer's own touchscreen -- which publishes nothing --
|
|
|
|
|
+ left ``current_project_url`` at None, and the storage verdict fell
|
|
|
|
|
+ through to the ``sdcard`` fallback for the one case it was written for.
|
|
|
|
|
+ On an H2S that flag is True (its "card" is the internal eMMC), so the
|
|
|
|
|
+ verdict came back reachable and the ~110-connection sweep ran in full.
|
|
|
|
|
+
|
|
|
|
|
+ The printer does announce it: an unsolicited ``project_file`` response
|
|
|
|
|
+ on the report topic, ~2 s before ``gcode_state`` reaches PREPARE,
|
|
|
|
|
+ carrying ``file:///userdata/model/history/<name>.gcode.3mf``.
|
|
|
|
|
+
|
|
|
|
|
+ This also covers an install nobody had in view: some brokers refuse the
|
|
|
|
|
+ request-topic subscription, and on those no print of any kind has ever
|
|
|
|
|
+ populated the field.
|
|
|
|
|
+
|
|
|
|
|
+ Both kinds of ``project_file`` on this topic are read -- the printer's
|
|
|
|
|
+ echo of a dispatch and a screen start -- because both name the
|
|
|
|
|
+ destination in ``url``, which is the only thing the verdict wants. What
|
|
|
|
|
+ this must NOT do is reuse ``_handle_request_message``'s "External
|
|
|
|
|
+ project_file payload" diagnostic: our own dispatch is echoed on *both*
|
|
|
|
|
+ topics, the request-topic echo arrives first and clears
|
|
|
|
|
+ ``_own_project_file_key``, so by the time this frame lands the key is
|
|
|
|
|
+ already None and every Bambuddy-started print would log itself as
|
|
|
|
|
+ someone else's.
|
|
|
|
|
+ """
|
|
|
|
|
+ # Same shape as _handle_request_message: the frame is whatever the
|
|
|
|
|
+ # printer put on the wire, and this is the first thing to touch it.
|
|
|
|
|
+ if not isinstance(print_data, dict) or print_data.get("command") != "project_file":
|
|
|
|
|
+ return
|
|
|
|
|
+ # A refused dispatch names a file that was never written. Acting on it
|
|
|
|
|
+ # would pin an archive on a destination nothing ever went to.
|
|
|
|
|
+ if print_data.get("result") != "SUCCESS":
|
|
|
|
|
+ return
|
|
|
|
|
+ url = print_data.get("url")
|
|
|
|
|
+ if not isinstance(url, str) or not url:
|
|
|
|
|
+ return
|
|
|
|
|
+ if self.state.current_project_url != url:
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "[%s] Print destination from the report topic: %s",
|
|
|
|
|
+ self.serial_number,
|
|
|
|
|
+ url,
|
|
|
|
|
+ )
|
|
|
|
|
+ self.state.current_project_url = url
|
|
|
|
|
+ self.state.last_project_url = url
|
|
|
|
|
+ # On a screen start this frame is the only place the mapping appears --
|
|
|
|
|
+ # no slicer ever sent one. Fill a gap only: when the request topic
|
|
|
|
|
+ # already captured this print's mapping that copy is the slicer's own,
|
|
|
|
|
+ # and the echo can arrive without the field at all.
|
|
|
|
|
+ if self._captured_ams_mapping is None and isinstance(print_data.get("ams_mapping"), list):
|
|
|
|
|
+ self._captured_ams_mapping = print_data["ams_mapping"]
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "[%s] Captured ams_mapping from print response: %s",
|
|
|
|
|
+ self.serial_number,
|
|
|
|
|
+ self._captured_ams_mapping,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ @staticmethod
|
|
|
|
|
+ def _project_file_key(print_data: dict) -> str:
|
|
|
|
|
+ """Identity of a project_file dispatch, for telling ours from a slicer's.
|
|
|
|
|
+
|
|
|
|
|
+ Sequence id alone cannot do it -- every slicer counts up from the same
|
|
|
|
|
+ 20000 -- so this also carries the file and its destination, which differ
|
|
|
|
|
+ between any two real dispatches.
|
|
|
|
|
+ """
|
|
|
|
|
+ return "|".join(str(print_data.get(field, "")) for field in ("sequence_id", "file", "url", "subtask_name"))
|
|
|
|
|
+
|
|
|
def _debug_on_change(self, key: str, value: object, msg: str, *args: object) -> None:
|
|
def _debug_on_change(self, key: str, value: object, msg: str, *args: object) -> None:
|
|
|
"""``logger.debug``, but only when ``value`` differs from the last call for ``key``.
|
|
"""``logger.debug``, but only when ``value`` differs from the last call for ``key``.
|
|
|
|
|
|
|
@@ -1937,6 +2174,11 @@ class BambuMQTTClient:
|
|
|
if "print" in payload:
|
|
if "print" in payload:
|
|
|
print_data = payload["print"]
|
|
print_data = payload["print"]
|
|
|
|
|
|
|
|
|
|
+ # Before anything reads the state: this is where a touchscreen-
|
|
|
|
|
+ # started print announces where its file lives, and the print-start
|
|
|
|
|
+ # handler asks ~2 s later (#1820).
|
|
|
|
|
+ self._capture_report_project_file(print_data)
|
|
|
|
|
+
|
|
|
# Check if xcam is nested inside print data
|
|
# Check if xcam is nested inside print data
|
|
|
if "xcam" in print_data:
|
|
if "xcam" in print_data:
|
|
|
logger.debug("[%s] Found xcam inside print data: %s", self.serial_number, print_data["xcam"])
|
|
logger.debug("[%s] Found xcam inside print data: %s", self.serial_number, print_data["xcam"])
|
|
@@ -1986,6 +2228,12 @@ class BambuMQTTClient:
|
|
|
self._is_dual_nozzle = True
|
|
self._is_dual_nozzle = True
|
|
|
logger.info("[%s] Detected dual-nozzle printer from device.extruder.info", self.serial_number)
|
|
logger.info("[%s] Detected dual-nozzle printer from device.extruder.info", self.serial_number)
|
|
|
|
|
|
|
|
|
|
+ # Must run before _handle_ams_data: the per-AMS inlet binding is read
|
|
|
|
|
+ # out of the AMS info bits, but only means anything once we know a
|
|
|
|
|
+ # switch is installed. Parsing them the other way round would lose
|
|
|
|
|
+ # the binding on every frame where the two arrive together.
|
|
|
|
|
+ self._parse_fila_switch(print_data)
|
|
|
|
|
+
|
|
|
# Handle AMS data that comes inside print key
|
|
# Handle AMS data that comes inside print key
|
|
|
if "ams" in print_data:
|
|
if "ams" in print_data:
|
|
|
try:
|
|
try:
|
|
@@ -2635,6 +2883,66 @@ class BambuMQTTClient:
|
|
|
)
|
|
)
|
|
|
self._has_a2l_am_unit = True
|
|
self._has_a2l_am_unit = True
|
|
|
|
|
|
|
|
|
|
+ def _parse_fila_switch(self, data: dict) -> None:
|
|
|
|
|
+ """Read the Filament Track Switch block out of a print payload — #1162.
|
|
|
|
|
+
|
|
|
|
|
+ Presence of ``device.fila_switch`` means the accessory is installed. Kept
|
|
|
|
|
+ separate from the rest of the state update because ``_handle_ams_data``
|
|
|
|
|
+ needs the answer before it parses the AMS info bits, and that runs first.
|
|
|
|
|
+ """
|
|
|
|
|
+ if not isinstance(data.get("device"), dict):
|
|
|
|
|
+ return
|
|
|
|
|
+ fs_data = data["device"].get("fila_switch")
|
|
|
|
|
+ if not isinstance(fs_data, dict):
|
|
|
|
|
+ return
|
|
|
|
|
+ in_raw = fs_data.get("in")
|
|
|
|
|
+ out_raw = fs_data.get("out")
|
|
|
|
|
+ self.state.fila_switch = FilaSwitchState(
|
|
|
|
|
+ installed=True,
|
|
|
|
|
+ in_slots=list(in_raw) if isinstance(in_raw, list) else [],
|
|
|
|
|
+ out_extruders=list(out_raw) if isinstance(out_raw, list) else [],
|
|
|
|
|
+ stat=int(fs_data.get("stat", 0) or 0),
|
|
|
|
|
+ info=int(fs_data.get("info", 0) or 0),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def _parse_extruder_slots(self, data: dict) -> None:
|
|
|
|
|
+ """Read which AMS slot each extruder is fed from — ``device.extruder.info``.
|
|
|
|
|
+
|
|
|
|
|
+ Absent on printers that do not report the block, in which case the
|
|
|
|
|
+ previous answer is kept rather than cleared: a partial payload carrying
|
|
|
|
|
+ only temperatures must not look like "both hotends are now empty".
|
|
|
|
|
+ """
|
|
|
|
|
+ device = data.get("device")
|
|
|
|
|
+ if not isinstance(device, dict):
|
|
|
|
|
+ return
|
|
|
|
|
+ info = device.get("extruder", {}).get("info") if isinstance(device.get("extruder"), dict) else None
|
|
|
|
|
+ if not isinstance(info, list) or not info:
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ slots: dict[int, ExtruderSlot] = {}
|
|
|
|
|
+ for entry in info:
|
|
|
|
|
+ if not isinstance(entry, dict) or "id" not in entry:
|
|
|
|
|
+ continue
|
|
|
|
|
+ try:
|
|
|
|
|
+ ext_id = int(entry["id"])
|
|
|
|
|
+ snow = int(entry.get("snow", _EXTRUDER_SLOT_EMPTY))
|
|
|
|
|
+ flags = int(entry.get("info", 0) or 0)
|
|
|
|
|
+ except (TypeError, ValueError):
|
|
|
|
|
+ continue
|
|
|
|
|
+ if snow == _EXTRUDER_SLOT_EMPTY or snow < 0:
|
|
|
|
|
+ ams_id = slot_id = None
|
|
|
|
|
+ else:
|
|
|
|
|
+ ams_id = (snow >> 8) & 0xFF
|
|
|
|
|
+ slot_id = snow & 0xFF
|
|
|
|
|
+ slots[ext_id] = ExtruderSlot(
|
|
|
|
|
+ ams_id=ams_id,
|
|
|
|
|
+ slot_id=slot_id,
|
|
|
|
|
+ has_filament=bool(flags & 0b10),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ if slots:
|
|
|
|
|
+ self.state.extruder_slots = slots
|
|
|
|
|
+
|
|
|
def _handle_ams_data(self, ams_data):
|
|
def _handle_ams_data(self, ams_data):
|
|
|
"""Handle AMS data changes for Spoolman integration.
|
|
"""Handle AMS data changes for Spoolman integration.
|
|
|
|
|
|
|
@@ -3143,13 +3451,25 @@ class BambuMQTTClient:
|
|
|
# BambuStudio DevFilaSystem.cpp parses info as hex string:
|
|
# BambuStudio DevFilaSystem.cpp parses info as hex string:
|
|
|
# type_id = get_flag_bits(info, 0, 4) // bits 0-3: AMS type
|
|
# type_id = get_flag_bits(info, 0, 4) // bits 0-3: AMS type
|
|
|
# extruder_id = get_flag_bits(info, 8, 4) // bits 8-11: extruder assignment
|
|
# extruder_id = get_flag_bits(info, 8, 4) // bits 8-11: extruder assignment
|
|
|
|
|
+ # bind_switch_in = get_flag_bits(info, 24, 4) // bits 24-27: FTS inlet
|
|
|
# where get_flag_bits uses std::stoull(str, nullptr, 16) — hex parsing.
|
|
# where get_flag_bits uses std::stoull(str, nullptr, 16) — hex parsing.
|
|
|
- # extruder_id: 0=right/main, 1=left/deputy, 0xE=uninitialized (skip)
|
|
|
|
|
|
|
+ # extruder_id: 0=right/main, 1=left/deputy, 0xE=routing is not fixed
|
|
|
|
|
+ #
|
|
|
|
|
+ # 0xE does not mean "broken". On a Filament Track Switch machine it is the
|
|
|
|
|
+ # normal steady state: the AMS is bound to a switch *inlet* rather than to
|
|
|
|
|
+ # one extruder, and reaches both nozzles through it. Bits 24-27 then name
|
|
|
|
|
+ # that inlet — 0 = In-B, 1 = In-A (BambuStudio's SwitchPos enum, which is
|
|
|
|
|
+ # ordered B-then-A). Without an FTS, 0xE really is an uninitialised unit
|
|
|
|
|
+ # and bits 24-27 carry nothing, which is why the inlet read is gated on
|
|
|
|
|
+ # the switch being installed.
|
|
|
#
|
|
#
|
|
|
# Use merged_ams (not ams_list) to avoid partial MQTT updates overwriting
|
|
# Use merged_ams (not ams_list) to avoid partial MQTT updates overwriting
|
|
|
# the full map. Merge into existing map to preserve entries from prior updates.
|
|
# the full map. Merge into existing map to preserve entries from prior updates.
|
|
|
|
|
|
|
|
|
|
+ fts_installed = self.state.fila_switch.installed
|
|
|
|
|
+ inlet_moves: list[tuple[int, str]] = []
|
|
|
ams_extruder_map = dict(self.state.ams_extruder_map) if self.state.ams_extruder_map else {}
|
|
ams_extruder_map = dict(self.state.ams_extruder_map) if self.state.ams_extruder_map else {}
|
|
|
|
|
+ ams_switch_inlet = dict(self.state.ams_switch_inlet) if self.state.ams_switch_inlet else {}
|
|
|
for ams_unit in merged_ams:
|
|
for ams_unit in merged_ams:
|
|
|
ams_id = ams_unit.get("id")
|
|
ams_id = ams_unit.get("id")
|
|
|
info = ams_unit.get("info")
|
|
info = ams_unit.get("info")
|
|
@@ -3160,7 +3480,32 @@ class BambuMQTTClient:
|
|
|
# Extract 4 bits starting at bit 8 for extruder assignment
|
|
# Extract 4 bits starting at bit 8 for extruder assignment
|
|
|
extruder_id = (info_val >> 8) & 0xF
|
|
extruder_id = (info_val >> 8) & 0xF
|
|
|
if extruder_id == 0xE:
|
|
if extruder_id == 0xE:
|
|
|
- # 0xE = uninitialized AMS, skip
|
|
|
|
|
|
|
+ if fts_installed:
|
|
|
|
|
+ inlet = {0: "B", 1: "A"}.get((info_val >> 24) & 0xF)
|
|
|
|
|
+ if inlet is not None:
|
|
|
|
|
+ previous = ams_switch_inlet.get(str(ams_id))
|
|
|
|
|
+ ams_switch_inlet[str(ams_id)] = inlet
|
|
|
|
|
+ self._debug_on_change(
|
|
|
|
|
+ f"ams_inlet:{ams_id}",
|
|
|
|
|
+ inlet,
|
|
|
|
|
+ "[%s] AMS %s info=0x%s -> FTS inlet %s",
|
|
|
|
|
+ self.serial_number,
|
|
|
|
|
+ ams_id,
|
|
|
|
|
+ info,
|
|
|
|
|
+ inlet,
|
|
|
|
|
+ )
|
|
|
|
|
+ if previous is not None and previous != inlet:
|
|
|
|
|
+ # Only a genuine move, never the first sighting:
|
|
|
|
|
+ # re-applying K-profiles on every reconnect would
|
|
|
|
|
+ # fight a binding the operator set deliberately.
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "[%s] AMS %s moved to FTS inlet %s (was %s)",
|
|
|
|
|
+ self.serial_number,
|
|
|
|
|
+ ams_id,
|
|
|
|
|
+ inlet,
|
|
|
|
|
+ previous,
|
|
|
|
|
+ )
|
|
|
|
|
+ inlet_moves.append((int(ams_id), inlet))
|
|
|
continue
|
|
continue
|
|
|
ams_extruder_map[str(ams_id)] = extruder_id
|
|
ams_extruder_map[str(ams_id)] = extruder_id
|
|
|
self._debug_on_change(
|
|
self._debug_on_change(
|
|
@@ -3178,6 +3523,11 @@ class BambuMQTTClient:
|
|
|
self.state.raw_data["ams_extruder_map"] = ams_extruder_map
|
|
self.state.raw_data["ams_extruder_map"] = ams_extruder_map
|
|
|
self.state.ams_extruder_map = ams_extruder_map
|
|
self.state.ams_extruder_map = ams_extruder_map
|
|
|
logger.debug("[%s] ams_extruder_map: %s", self.serial_number, ams_extruder_map)
|
|
logger.debug("[%s] ams_extruder_map: %s", self.serial_number, ams_extruder_map)
|
|
|
|
|
+ if ams_switch_inlet:
|
|
|
|
|
+ self.state.ams_switch_inlet = ams_switch_inlet
|
|
|
|
|
+ for moved_ams_id, moved_inlet in inlet_moves:
|
|
|
|
|
+ if self.on_fts_inlet_change:
|
|
|
|
|
+ self.on_fts_inlet_change(moved_ams_id, moved_inlet)
|
|
|
|
|
|
|
|
# Extract drying status from info hex string and dry_sf_reason per AMS unit
|
|
# Extract drying status from info hex string and dry_sf_reason per AMS unit
|
|
|
# BambuStudio DevFilaSystem.cpp parses info bits:
|
|
# BambuStudio DevFilaSystem.cpp parses info bits:
|
|
@@ -3240,7 +3590,7 @@ class BambuMQTTClient:
|
|
|
# schedules smart-plug auto-off. dry_status comes from the same info
|
|
# schedules smart-plug auto-off. dry_status comes from the same info
|
|
|
# hex parsed above; when it is absent we let the edge through, so a
|
|
# hex parsed above; when it is absent we let the edge through, so a
|
|
|
# firmware that never reports one still ends its cycles.
|
|
# firmware that never reports one still ends its cycles.
|
|
|
- if current == 0 and ams_unit.get("dry_status") in _ACTIVE_DRY_STATUSES:
|
|
|
|
|
|
|
+ if current == 0 and ams_unit.get("dry_status") in ACTIVE_DRY_STATUSES:
|
|
|
# Leave the remembered value alone, exactly as the absent-
|
|
# Leave the remembered value alone, exactly as the absent-
|
|
|
# dry_time skip above does: whichever push ends the cycle for
|
|
# dry_time skip above does: whichever push ends the cycle for
|
|
|
# real must still see a non-zero previous.
|
|
# real must still see a non-zero previous.
|
|
@@ -3931,20 +4281,11 @@ class BambuMQTTClient:
|
|
|
if "cur" in ext_data:
|
|
if "cur" in ext_data:
|
|
|
logger.debug("[%s] device.extruder.cur: %s", self.serial_number, ext_data["cur"])
|
|
logger.debug("[%s] device.extruder.cur: %s", self.serial_number, ext_data["cur"])
|
|
|
|
|
|
|
|
- # Filament Track Switch (FTS) detection — #1162. Presence of
|
|
|
|
|
- # device.fila_switch in MQTT means the FTS accessory is installed.
|
|
|
|
|
- if "device" in data and isinstance(data.get("device"), dict):
|
|
|
|
|
- fs_data = data["device"].get("fila_switch")
|
|
|
|
|
- if isinstance(fs_data, dict):
|
|
|
|
|
- in_raw = fs_data.get("in")
|
|
|
|
|
- out_raw = fs_data.get("out")
|
|
|
|
|
- self.state.fila_switch = FilaSwitchState(
|
|
|
|
|
- installed=True,
|
|
|
|
|
- in_slots=list(in_raw) if isinstance(in_raw, list) else [],
|
|
|
|
|
- out_extruders=list(out_raw) if isinstance(out_raw, list) else [],
|
|
|
|
|
- stat=int(fs_data.get("stat", 0) or 0),
|
|
|
|
|
- info=int(fs_data.get("info", 0) or 0),
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ # Also parsed earlier in _process_message, because _handle_ams_data needs
|
|
|
|
|
+ # it first. Repeated here so _update_state stays a complete "absorb this
|
|
|
|
|
+ # payload" step for any other caller; re-parsing the same block is free.
|
|
|
|
|
+ self._parse_fila_switch(data)
|
|
|
|
|
+ self._parse_extruder_slots(data)
|
|
|
|
|
|
|
|
if "bed_temper" in data:
|
|
if "bed_temper" in data:
|
|
|
temps["bed"] = float(data["bed_temper"])
|
|
temps["bed"] = float(data["bed_temper"])
|
|
@@ -4461,6 +4802,7 @@ class BambuMQTTClient:
|
|
|
actions=actions,
|
|
actions=actions,
|
|
|
job_id=self.state.subtask_id,
|
|
job_id=self.state.subtask_id,
|
|
|
full_code=full_code,
|
|
full_code=full_code,
|
|
|
|
|
+ description=describe_fault(full_code),
|
|
|
)
|
|
)
|
|
|
)
|
|
)
|
|
|
self._apply_mqtt_verify_state(verify_failed)
|
|
self._apply_mqtt_verify_state(verify_failed)
|
|
@@ -4536,6 +4878,7 @@ class BambuMQTTClient:
|
|
|
# print_error is already 32-bit — `f"{print_error:08X}"`
|
|
# print_error is already 32-bit — `f"{print_error:08X}"`
|
|
|
# is the firmware's matching key with no truncation.
|
|
# is the firmware's matching key with no truncation.
|
|
|
full_code=f"{print_error:08X}",
|
|
full_code=f"{print_error:08X}",
|
|
|
|
|
+ description=describe_fault(f"{print_error:08X}"),
|
|
|
)
|
|
)
|
|
|
)
|
|
)
|
|
|
|
|
|
|
@@ -5113,7 +5456,16 @@ class BambuMQTTClient:
|
|
|
# Include HMS errors for failure reason detection
|
|
# Include HMS errors for failure reason detection
|
|
|
hms_errors_data = (
|
|
hms_errors_data = (
|
|
|
[
|
|
[
|
|
|
- {"code": e.code, "attr": e.attr, "module": e.module, "severity": e.severity}
|
|
|
|
|
|
|
+ {
|
|
|
|
|
+ "code": e.code,
|
|
|
|
|
+ "attr": e.attr,
|
|
|
|
|
+ "module": e.module,
|
|
|
|
|
+ "severity": e.severity,
|
|
|
|
|
+ # Carried so the queue's failure reason quotes the same
|
|
|
|
|
+ # sentence the status response and the broadcast do,
|
|
|
|
|
+ # rather than resolving the code a fourth time (#2926).
|
|
|
|
|
+ "description": e.description,
|
|
|
|
|
+ }
|
|
|
for e in self.state.hms_errors
|
|
for e in self.state.hms_errors
|
|
|
]
|
|
]
|
|
|
if self.state.hms_errors
|
|
if self.state.hms_errors
|
|
@@ -5753,6 +6105,9 @@ class BambuMQTTClient:
|
|
|
command["print"]["nozzle_mapping"] = resolved
|
|
command["print"]["nozzle_mapping"] = resolved
|
|
|
|
|
|
|
|
logger.info("[%s] Sending print command: %s", self.serial_number, json.dumps(command))
|
|
logger.info("[%s] Sending print command: %s", self.serial_number, json.dumps(command))
|
|
|
|
|
+ # Remember this dispatch so its echo on the topic is recognised as
|
|
|
|
|
+ # ours rather than logged as a slicer's.
|
|
|
|
|
+ self._own_project_file_key = self._project_file_key(command["print"])
|
|
|
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
|
|
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
|
|
|
# Record what we dispatched so /cover can pick the right plate
|
|
# Record what we dispatched so /cover can pick the right plate
|
|
|
# thumbnail even when the printer's gcode_file echo is just the
|
|
# thumbnail even when the printer's gcode_file echo is just the
|
|
@@ -6193,6 +6548,33 @@ class BambuMQTTClient:
|
|
|
logger.debug("Failed to parse K-profile from broadcast: %s", e)
|
|
logger.debug("Failed to parse K-profile from broadcast: %s", e)
|
|
|
return profiles
|
|
return profiles
|
|
|
|
|
|
|
|
|
|
+ def _store_kprofiles(self, profiles: list, response_nozzle: str | None) -> None:
|
|
|
|
|
+ """File one calibration-table response under its nozzle diameter.
|
|
|
|
|
+
|
|
|
|
|
+ ``response_nozzle`` names the table the printer just sent, so that
|
|
|
|
|
+ bucket is replaced wholesale and every other one is left alone. When
|
|
|
|
|
+ the envelope carries no diameter, fall back to the diameters the parsed
|
|
|
|
|
+ profiles claim for themselves — and if there are none of those either,
|
|
|
|
|
+ keep what we have rather than dropping a table we cannot attribute.
|
|
|
|
|
+
|
|
|
|
|
+ ``state.kprofiles`` stays a flat list because that is what its readers
|
|
|
|
|
+ expect; the three assign paths already filter it by ``nozzle_diameter``
|
|
|
|
|
+ and were quietly finding nothing whenever the last response happened to
|
|
|
|
|
+ be for a different nozzle.
|
|
|
|
|
+ """
|
|
|
|
|
+ buckets: dict[str, list] = {}
|
|
|
|
|
+ if response_nozzle:
|
|
|
|
|
+ buckets[str(response_nozzle)] = list(profiles)
|
|
|
|
|
+ else:
|
|
|
|
|
+ for profile in profiles:
|
|
|
|
|
+ buckets.setdefault(str(profile.nozzle_diameter), []).append(profile)
|
|
|
|
|
+ if not buckets:
|
|
|
|
|
+ return
|
|
|
|
|
+ self._kprofiles_by_nozzle.update(buckets)
|
|
|
|
|
+ self.state.kprofiles = [
|
|
|
|
|
+ kp for nozzle in sorted(self._kprofiles_by_nozzle) for kp in self._kprofiles_by_nozzle[nozzle]
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
def _handle_kprofile_response(self, data: dict):
|
|
def _handle_kprofile_response(self, data: dict):
|
|
|
"""Handle K-profile response from printer."""
|
|
"""Handle K-profile response from printer."""
|
|
|
response_nozzle = data.get("nozzle_diameter")
|
|
response_nozzle = data.get("nozzle_diameter")
|
|
@@ -6240,11 +6622,20 @@ class BambuMQTTClient:
|
|
|
return
|
|
return
|
|
|
|
|
|
|
|
profiles = self._parse_kprofile_entries(filaments, response_nozzle, log_errors=request is not None)
|
|
profiles = self._parse_kprofile_entries(filaments, response_nozzle, log_errors=request is not None)
|
|
|
- self.state.kprofiles = profiles
|
|
|
|
|
|
|
+ self._store_kprofiles(profiles, response_nozzle)
|
|
|
|
|
|
|
|
if request is None:
|
|
if request is None:
|
|
|
# Unsolicited broadcast with nothing in flight: state is refreshed,
|
|
# Unsolicited broadcast with nothing in flight: state is refreshed,
|
|
|
- # nobody to wake.
|
|
|
|
|
|
|
+ # nobody to wake. Worth a line — this is the printer answering
|
|
|
|
|
+ # somebody else (BambuStudio queries the same report topic), and
|
|
|
|
|
+ # until it was bucketed by nozzle it was also the quietest way for
|
|
|
|
|
+ # the AMS card's K values to change underneath us.
|
|
|
|
|
+ logger.debug(
|
|
|
|
|
+ "[%s] Adopted unsolicited K-profile table: nozzle=%s, %d profiles",
|
|
|
|
|
+ self.serial_number,
|
|
|
|
|
+ response_nozzle or "?",
|
|
|
|
|
+ len(profiles),
|
|
|
|
|
+ )
|
|
|
return
|
|
return
|
|
|
|
|
|
|
|
logger.info("[%s] Got %s K-profiles for nozzle=%s", self.serial_number, len(profiles), response_nozzle)
|
|
logger.info("[%s] Got %s K-profiles for nozzle=%s", self.serial_number, len(profiles), response_nozzle)
|
|
@@ -6982,7 +7373,16 @@ class BambuMQTTClient:
|
|
|
tray_id: Global tray ID — 0..15 for AMS slots, 254 for external spool
|
|
tray_id: Global tray ID — 0..15 for AMS slots, 254 for external spool
|
|
|
(single-external printers and Ext-L on dual-nozzle H2D),
|
|
(single-external printers and Ext-L on dual-nozzle H2D),
|
|
|
255 for Ext-R on dual-nozzle H2D.
|
|
255 for Ext-R on dual-nozzle H2D.
|
|
|
- extruder_id: Unused - kept for API compatibility
|
|
|
|
|
|
|
+ extruder_id: Which hotend to feed (0 = right/main, 1 = left/deputy).
|
|
|
|
|
+ Sent only when given, matching BambuStudio: ``extruder_id`` is
|
|
|
|
|
+ an optional field on ``ams_change_filament``
|
|
|
|
|
+ (``DeviceManager::command_ams_change_filament``) and Studio
|
|
|
|
|
+ omits it unless a Filament Track Switch is installed. Without a
|
|
|
|
|
+ switch the firmware derives the hotend from the AMS's own
|
|
|
|
|
+ extruder binding and an explicit value is redundant; *with* one
|
|
|
|
|
+ every AMS reports 0xE and is bound to a switch inlet instead, so
|
|
|
|
|
+ the firmware has nothing to derive from and the load silently
|
|
|
|
|
+ does nothing until we name the hotend.
|
|
|
|
|
|
|
|
Returns:
|
|
Returns:
|
|
|
True if command was sent, False otherwise
|
|
True if command was sent, False otherwise
|
|
@@ -7037,6 +7437,8 @@ class BambuMQTTClient:
|
|
|
"tar_temp": tar_temp,
|
|
"tar_temp": tar_temp,
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+ if extruder_id is not None:
|
|
|
|
|
+ command["print"]["extruder_id"] = int(extruder_id)
|
|
|
|
|
|
|
|
command_json = json.dumps(command)
|
|
command_json = json.dumps(command)
|
|
|
logger.info("[%s] Publishing ams_change_filament command: %s", self.serial_number, command_json)
|
|
logger.info("[%s] Publishing ams_change_filament command: %s", self.serial_number, command_json)
|
|
@@ -7051,8 +7453,21 @@ class BambuMQTTClient:
|
|
|
|
|
|
|
|
return True
|
|
return True
|
|
|
|
|
|
|
|
- def ams_unload_filament(self) -> bool:
|
|
|
|
|
- """Unload the currently loaded filament.
|
|
|
|
|
|
|
+ def ams_unload_filament(self, tray_id: int | None = None) -> bool:
|
|
|
|
|
+ """Unload filament, optionally naming the slot to unload.
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ tray_id: Global tray ID of the slot being unloaded. When given, the
|
|
|
|
|
+ command is addressed to that slot's AMS and is only sent if an
|
|
|
|
|
+ extruder is actually fed from it — BambuStudio does the same
|
|
|
|
|
+ (``StatusPanel::on_ams_unload`` walks the extruders and sends
|
|
|
|
|
+ nothing when none matches). When omitted, the pre-existing
|
|
|
|
|
+ behaviour is kept: unload whatever ``tray_now`` names.
|
|
|
|
|
+
|
|
|
|
|
+ ``tray_now`` is a single value for the whole printer, so on a dual-nozzle
|
|
|
|
|
+ machine with both hotends loaded it names only one of them and an
|
|
|
|
|
+ unaddressed unload picks that one regardless of which slot the operator
|
|
|
|
|
+ clicked. Passing the slot is what makes the two hotends distinguishable.
|
|
|
|
|
|
|
|
Returns:
|
|
Returns:
|
|
|
True if command was sent, False otherwise
|
|
True if command was sent, False otherwise
|
|
@@ -7063,15 +7478,54 @@ class BambuMQTTClient:
|
|
|
|
|
|
|
|
# Get the currently loaded tray info
|
|
# Get the currently loaded tray info
|
|
|
tray_now = self.state.tray_now
|
|
tray_now = self.state.tray_now
|
|
|
- logger.info("[%s] Unload requested, tray_now=%s", self.serial_number, tray_now)
|
|
|
|
|
|
|
+ source_tray = tray_now if tray_id is None else tray_id
|
|
|
|
|
+ logger.info("[%s] Unload requested, tray_now=%s, tray_id=%s", self.serial_number, tray_now, tray_id)
|
|
|
|
|
|
|
|
# Determine source ams_id for the unload command
|
|
# Determine source ams_id for the unload command
|
|
|
- if tray_now == 255 or tray_now == 254:
|
|
|
|
|
|
|
+ if source_tray == 255 or source_tray == 254:
|
|
|
ams_id = 255 # No filament or external spool
|
|
ams_id = 255 # No filament or external spool
|
|
|
- elif (_a2l := a2l_lite_wire_ids(tray_now // 4, tray_now)) is not None:
|
|
|
|
|
|
|
+ elif (_a2l := a2l_lite_wire_ids(source_tray // 4, source_tray)) is not None:
|
|
|
ams_id = _a2l[0] # A2L AMS-Lite: normalised 6 -> physical 16
|
|
ams_id = _a2l[0] # A2L AMS-Lite: normalised 6 -> physical 16
|
|
|
else:
|
|
else:
|
|
|
- ams_id = tray_now // 4 # Source AMS
|
|
|
|
|
|
|
+ ams_id = source_tray // 4 # Source AMS
|
|
|
|
|
+
|
|
|
|
|
+ # Refuse an addressed unload of a slot no hotend is holding — but only on
|
|
|
|
|
+ # a printer that has more than one hotend, which is the only case the
|
|
|
|
|
+ # check exists for. With one hotend there is nothing to disambiguate:
|
|
|
|
|
+ # tray_now already names the loaded slot exactly, and running the check
|
|
|
|
|
+ # anyway would stake unload on `snow` meaning ams*4+slot there too. It
|
|
|
|
|
+ # very likely does, but single-nozzle machines do report the block —
|
|
|
|
|
+ # BambuStudio has a dedicated branch for `m_total_extder_count == 1` and
|
|
|
|
|
+ # an X1C on the maintainer's own network sends `device.extruder` — and
|
|
|
|
|
+ # nobody has read a single-nozzle `snow` off the wire. Guessing wrong
|
|
|
|
|
+ # would 409 every unload on every X1C, P1S and A1.
|
|
|
|
|
+ #
|
|
|
|
|
+ # Gated on the runtime flag rather than on len(extruder_slots), which is
|
|
|
|
|
+ # rebuilt from each payload's array and would flip the check off for any
|
|
|
|
|
+ # frame that carried a short one; and deliberately not on
|
|
|
|
|
+ # ``is_dual_nozzle_model``, whose model-name fallback reports at least
|
|
|
|
|
+ # one single-nozzle machine as dual (#1386) — the false positive there is
|
|
|
|
|
+ # exactly the case this gate exists to keep out.
|
|
|
|
|
+ #
|
|
|
|
|
+ # The external spool is excluded for a different reason: 254/255 are not
|
|
|
|
|
+ # ams*4+slot, so the local-slot arithmetic below cannot describe them.
|
|
|
|
|
+ if tray_id is not None and tray_id not in (254, 255) and self._is_dual_nozzle and self.state.extruder_slots:
|
|
|
|
|
+ local_slot = _a2l[1] if (_a2l := a2l_lite_wire_ids(tray_id // 4, tray_id)) is not None else tray_id % 4
|
|
|
|
|
+ holder = next(
|
|
|
|
|
+ (ext for ext, slot in self.state.extruder_slots.items() if slot.holds(ams_id, local_slot)),
|
|
|
|
|
+ None,
|
|
|
|
|
+ )
|
|
|
|
|
+ if holder is None:
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "[%s] Unload skipped: no extruder is fed from AMS %s slot %s",
|
|
|
|
|
+ self.serial_number,
|
|
|
|
|
+ ams_id,
|
|
|
|
|
+ local_slot,
|
|
|
|
|
+ )
|
|
|
|
|
+ return False
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "[%s] Unloading AMS %s slot %s from extruder %s", self.serial_number, ams_id, local_slot, holder
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
# Command format from BambuStudio traffic capture:
|
|
# Command format from BambuStudio traffic capture:
|
|
|
# - No extruder_id field
|
|
# - No extruder_id field
|
|
@@ -7254,7 +7708,9 @@ class BambuMQTTClient:
|
|
|
"tray_info_idx": tray_info_idx,
|
|
"tray_info_idx": tray_info_idx,
|
|
|
"tray_type": tray_type,
|
|
"tray_type": tray_type,
|
|
|
"tray_sub_brands": tray_sub_brands,
|
|
"tray_sub_brands": tray_sub_brands,
|
|
|
- "tray_color": tray_color,
|
|
|
|
|
|
|
+ # UPPERCASE, always: lowercase hex is silently read as zeros by
|
|
|
|
|
+ # P1S firmware and acknowledged as a success (#2987).
|
|
|
|
|
+ "tray_color": wire_tray_color(tray_color),
|
|
|
"nozzle_temp_min": nozzle_temp_min,
|
|
"nozzle_temp_min": nozzle_temp_min,
|
|
|
"nozzle_temp_max": nozzle_temp_max,
|
|
"nozzle_temp_max": nozzle_temp_max,
|
|
|
"sequence_id": "0",
|
|
"sequence_id": "0",
|