|
|
@@ -26,6 +26,24 @@ from backend.app.services.hms_errors import alert_level_from_print_error, descri
|
|
|
from backend.app.utils.ams_drying import ACTIVE_DRY_STATUSES, DRY_COUNTDOWN_STALL_SECONDS
|
|
|
from backend.app.utils.ams_humidity import ams_humidity_percent
|
|
|
from backend.app.utils.paho_teardown import discard_queued_publishes, retire_paho_client
|
|
|
+from backend.app.utils.tray_ids import (
|
|
|
+ AMS3H_UNIT_ID,
|
|
|
+ RACK_SLOT_COUNT,
|
|
|
+ AmsType,
|
|
|
+ TrayLayout,
|
|
|
+ ams_type_from_info,
|
|
|
+ decode_wire_slot,
|
|
|
+ external_tray_id,
|
|
|
+ global_tray_id,
|
|
|
+ is_ams_ht_id,
|
|
|
+ is_external_tray,
|
|
|
+ is_rack_ams,
|
|
|
+ legacy_tray_index,
|
|
|
+ mapped_trays_with_slot,
|
|
|
+ tray_flag_bit,
|
|
|
+ unit_ams_type,
|
|
|
+ unit_label,
|
|
|
+)
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@@ -40,7 +58,8 @@ PENDING_TRAY_TARGET_TTL = 120.0
|
|
|
# "ams_f1/<id>" – AMS Lite (A1, A1 Mini; the A2L reports id 16, stored as 6)
|
|
|
# "n3f/<id>" – AMS 2 Pro (H2D Pro and similar)
|
|
|
# "n3s/<id>" – AMS HT (H2D Pro and similar; IDs typically start at 128)
|
|
|
-_AMS_MODULE_PREFIXES = ("ams/", "ams_f1/", "n3f/", "n3s/")
|
|
|
+# "n10a" – AMS 3H (H3C), no suffix: read as "n10a/<its unit id>"
|
|
|
+_AMS_MODULE_PREFIXES = ("ams/", "ams_f1/", "n3f/", "n3s/", "n10a/")
|
|
|
|
|
|
# gcode_state values that mean the printer is not idle and must not be handed a
|
|
|
# new start-print (#2598). The firmware rejects a project_file while busy with
|
|
|
@@ -198,11 +217,11 @@ def a2l_lite_wire_ids(ams_id: int, tray_id: int) -> tuple[int, int, int] | None:
|
|
|
return (
|
|
|
A2L_LITE_PHYSICAL_AMS_ID,
|
|
|
local_slot,
|
|
|
- A2L_LITE_PHYSICAL_AMS_ID * 4 + local_slot,
|
|
|
+ legacy_tray_index(A2L_LITE_PHYSICAL_AMS_ID, local_slot),
|
|
|
)
|
|
|
|
|
|
|
|
|
-def change_filament_wire_ids(tray_id: int) -> tuple[int, int, int]:
|
|
|
+def change_filament_wire_ids(tray_id: int, layout: TrayLayout | None = None) -> tuple[int, int, int]:
|
|
|
"""``(ams_id, slot_id, target)`` of ``ams_change_filament`` for an AMS tray.
|
|
|
|
|
|
As BambuStudio's ``command_ams_change_filament`` builds them: a regular AMS
|
|
|
@@ -211,14 +230,106 @@ def change_filament_wire_ids(tray_id: int) -> tuple[int, int, int]:
|
|
|
the unit id itself as target. An AMS-HT holds one tray, so its global tray
|
|
|
id is its unit id and its slot is 0.
|
|
|
"""
|
|
|
- if 128 <= tray_id <= 135:
|
|
|
+ if is_ams_ht_id(tray_id):
|
|
|
return tray_id, 0, tray_id
|
|
|
- if (a2l := a2l_lite_wire_ids(tray_id // 4, tray_id)) is not None:
|
|
|
+ ams_id, slot_id = (layout or TrayLayout()).slot_key(tray_id)
|
|
|
+ if (a2l := a2l_lite_wire_ids(ams_id, tray_id)) is not None:
|
|
|
return a2l[0], a2l[1], a2l[0]
|
|
|
- return tray_id // 4, tray_id % 4, tray_id
|
|
|
+ return ams_id, slot_id, tray_id
|
|
|
+
|
|
|
+
|
|
|
+# The H3C's AMS 3H is one unit (32, AMS type 6) with nine slots, numbered 28 + k
|
|
|
+# (utils.tray_ids). Its tray_now / tray_tar / tray_pre are the raw slot 0-8;
|
|
|
+# print.mapping and the snow fields encode a slot as (32 << 8) | k. Commands
|
|
|
+# address it as {ams_id: 32, slot_id: k}.
|
|
|
+AMS3H_MODULE_NAME = "n10a" # its get_version module (no "/<unit>" suffix)
|
|
|
+
|
|
|
+
|
|
|
+def _mqtt_int(value: object) -> int | None:
|
|
|
+ """An MQTT id / number field as an int (they arrive as int or numeric str), else None."""
|
|
|
+ if isinstance(value, bool) or not isinstance(value, (int, str)):
|
|
|
+ return None
|
|
|
+ try:
|
|
|
+ return int(value)
|
|
|
+ except ValueError:
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+# AMS 3H drying (Bambu Studio 02.09, AMSDryControl.cpp): the unit reports
|
|
|
+# `zone_info[]` and only a zone with exactly two slots can dry; the others are
|
|
|
+# ambient readings. A drying start carries the filament's softening temperature
|
|
|
+# as `cooling_temp` (the preset's filament_dev_drying_softening_temperature).
|
|
|
+AMS3H_DRY_COOLING_TEMP = {"PLA": 65, "PETG": 75, "ABS": 95, "ASA": 95, "TPU": 70, "PA": 150, "PC": 120}
|
|
|
+AMS3H_DRY_COOLING_TEMP_DEFAULT = 50
|
|
|
+
|
|
|
+
|
|
|
+def ams3h_drying_zone(zone_info: object) -> dict | None:
|
|
|
+ """The zone of an AMS 3H's `zone_info` that can dry: the first holding exactly two slots."""
|
|
|
+ if not isinstance(zone_info, list):
|
|
|
+ return None
|
|
|
+ for zone in zone_info:
|
|
|
+ if isinstance(zone, dict) and isinstance(zone.get("zone_slots"), list) and len(zone["zone_slots"]) == 2:
|
|
|
+ return zone
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+def ams3h_drying_cooling_temp(filament: str) -> int:
|
|
|
+ """`cooling_temp` for an AMS 3H drying start: the filament's softening temperature."""
|
|
|
+ base = (filament or "").upper().split("-")[0].split(" ")[0]
|
|
|
+ return AMS3H_DRY_COOLING_TEMP.get(base, AMS3H_DRY_COOLING_TEMP_DEFAULT)
|
|
|
+
|
|
|
+
|
|
|
+def _ams3h_unit_drying_fields(unit: dict) -> dict:
|
|
|
+ """An AMS 3H's drying state, which lives on its drying zone, lifted onto the unit."""
|
|
|
+ zone = ams3h_drying_zone(unit.get("zone_info"))
|
|
|
+ if zone is None:
|
|
|
+ return {}
|
|
|
+ fields: dict = {"dry_zone_id": zone.get("zone_id", 0), "dry_zone_slots": list(zone.get("zone_slots") or [])}
|
|
|
+ for key in ("dry_time", "dry_setting", "dry_sf_reason"):
|
|
|
+ if key in zone:
|
|
|
+ fields[key] = zone[key]
|
|
|
+ if "info" not in unit and "info" in zone:
|
|
|
+ fields["info"] = zone["info"]
|
|
|
+ if "humidity_raw" in zone:
|
|
|
+ fields["dry_zone_humidity_raw"] = zone["humidity_raw"]
|
|
|
+ if "temp" in zone:
|
|
|
+ fields["dry_zone_temp"] = zone["temp"]
|
|
|
+ return fields
|
|
|
+
|
|
|
+
|
|
|
+def _ams3h_unit_env_fields(unit: dict) -> dict:
|
|
|
+ """An AMS 3H's humidity and temperature (the unit has none) from its first non-drying zone, as Studio shows it."""
|
|
|
+ zones = unit.get("zone_info")
|
|
|
+ if not isinstance(zones, list):
|
|
|
+ return {}
|
|
|
+ dry_zone = ams3h_drying_zone(zones)
|
|
|
+ ambient = next((z for z in zones if isinstance(z, dict) and z is not dry_zone), None)
|
|
|
+ if ambient is None:
|
|
|
+ return {}
|
|
|
+ return {key: ambient[key] for key in ("humidity_raw", "temp") if key in ambient and key not in unit}
|
|
|
+
|
|
|
+
|
|
|
+def apply_rack_ams_zone_fields(ams_list: object, known_types: dict[int, AmsType] | None = None) -> None:
|
|
|
+ """Lift a rack AMS's per-zone readings and drying state onto the unit, in place (type from `info`, else ``known_types``)."""
|
|
|
+ if not isinstance(ams_list, list):
|
|
|
+ return
|
|
|
+ for unit in ams_list:
|
|
|
+ if not isinstance(unit, dict) or not isinstance(unit.get("zone_info"), list):
|
|
|
+ continue
|
|
|
+ uid = _mqtt_int(unit.get("id"))
|
|
|
+ if uid is None:
|
|
|
+ continue
|
|
|
+ kind = ams_type_from_info(unit.get("info"))
|
|
|
+ if kind is None:
|
|
|
+ kind = (known_types or {}).get(uid, unit_ams_type(uid))
|
|
|
+ if not is_rack_ams(kind):
|
|
|
+ continue
|
|
|
+ unit.update({**_ams3h_unit_env_fields(unit), **_ams3h_unit_drying_fields(unit)})
|
|
|
|
|
|
|
|
|
-def resolve_external_spools_in_mapping(ams_mapping: object, ams_mapping2: object, is_dual_nozzle: bool) -> object:
|
|
|
+def resolve_external_spools_in_mapping(
|
|
|
+ ams_mapping: object, ams_mapping2: object, is_dual_nozzle: bool, layout: TrayLayout | None = None
|
|
|
+) -> object:
|
|
|
"""Put the external spool back into a captured flat ``ams_mapping`` (#3166).
|
|
|
|
|
|
The firmware rejects 254/255 in the flat list, so BambuStudio and our own
|
|
|
@@ -237,7 +348,9 @@ def resolve_external_spools_in_mapping(ams_mapping: object, ams_mapping2: object
|
|
|
An A2L AMS-Lite tray is the local slot 0-3 in the flat list and
|
|
|
``{ams_id: 16, slot_id: n}`` in ``ams_mapping2``; it becomes its normalised
|
|
|
global tray 24+n. Kept local, it read as a regular AMS tray, so usage was
|
|
|
- charged to the wrong spool. Every other entry is left exactly as captured.
|
|
|
+ charged to the wrong spool. A rack AMS slot (``{ams_id: 32, slot_id: k}``)
|
|
|
+ becomes its global tray 28+k, and on a rack printer each external input
|
|
|
+ 247-255 keeps its own id. Every other entry is left exactly as captured.
|
|
|
Without a usable ``ams_mapping2`` the input is returned unchanged.
|
|
|
"""
|
|
|
if not isinstance(ams_mapping, list) or not isinstance(ams_mapping2, list):
|
|
|
@@ -245,6 +358,7 @@ def resolve_external_spools_in_mapping(ams_mapping: object, ams_mapping2: object
|
|
|
if len(ams_mapping2) != len(ams_mapping):
|
|
|
return ams_mapping
|
|
|
resolved = list(ams_mapping)
|
|
|
+ layout = layout or TrayLayout()
|
|
|
for i, (flat, detail) in enumerate(zip(ams_mapping, ams_mapping2, strict=True)):
|
|
|
if not isinstance(detail, dict):
|
|
|
continue
|
|
|
@@ -253,9 +367,18 @@ def resolve_external_spools_in_mapping(ams_mapping: object, ams_mapping2: object
|
|
|
if ams_id == A2L_LITE_PHYSICAL_AMS_ID and slot_id in (0, 1, 2, 3):
|
|
|
resolved[i] = A2L_LITE_GLOBAL_BASE + slot_id
|
|
|
continue
|
|
|
- if flat != -1 or ams_id not in (254, 255) or slot_id == 255:
|
|
|
+ if (
|
|
|
+ isinstance(ams_id, int)
|
|
|
+ and isinstance(slot_id, int)
|
|
|
+ and not is_external_tray(ams_id, layout.rack)
|
|
|
+ and is_rack_ams(layout.ams_type(ams_id))
|
|
|
+ and 0 <= slot_id < RACK_SLOT_COUNT
|
|
|
+ ):
|
|
|
+ resolved[i] = layout.global_tray_id(ams_id, slot_id)
|
|
|
+ continue
|
|
|
+ if flat != -1 or not is_external_tray(ams_id, layout.rack) or slot_id == 255:
|
|
|
continue
|
|
|
- resolved[i] = ams_id if is_dual_nozzle else 254
|
|
|
+ resolved[i] = ams_id if is_dual_nozzle or layout.rack else 254
|
|
|
return resolved
|
|
|
|
|
|
|
|
|
@@ -376,9 +499,10 @@ def apply_tray_exist_bits(
|
|
|
ams_id = normalize_am_unit_id(ams_id)
|
|
|
# AMS-HT (n3s, id 128-135): single tray, presence bit at 16+(ams_id-128).
|
|
|
# Regular AMS (and the A2L-Lite normalised to id 6): ams_id*4 + tray_id.
|
|
|
- # Anything outside those ranges has no known bit layout — don't guess it.
|
|
|
- is_ht = 128 <= ams_id <= 135
|
|
|
- if not is_ht and not (0 <= ams_id <= 15):
|
|
|
+ # A rack AMS (unit 32): its global tray id 28 + slot.
|
|
|
+ # Anything outside those has no known bit layout — don't guess it.
|
|
|
+ kind = unit_ams_type(ams_id, ams_unit.get("info"))
|
|
|
+ if tray_flag_bit(ams_id, 0, kind) is None:
|
|
|
continue
|
|
|
for tray in ams_unit.get("tray", []):
|
|
|
if not isinstance(tray, dict):
|
|
|
@@ -392,7 +516,9 @@ def apply_tray_exist_bits(
|
|
|
continue
|
|
|
if not isinstance(tray_id, int):
|
|
|
continue
|
|
|
- global_bit = (16 + (ams_id - 128)) if is_ht else (ams_id * 4 + tray_id)
|
|
|
+ global_bit = tray_flag_bit(ams_id, tray_id, kind)
|
|
|
+ if global_bit is None or global_bit < 0:
|
|
|
+ continue
|
|
|
slot_exists = (tray_exist_bits >> global_bit) & 1
|
|
|
if annotate_exists:
|
|
|
tray["exists"] = bool(slot_exists)
|
|
|
@@ -1652,6 +1778,11 @@ class BambuMQTTClient:
|
|
|
# See normalize_am_unit_id / a2l_lite_wire_ids and memory a2l-am-unit-16.
|
|
|
self._has_a2l_am_unit: bool = False
|
|
|
|
|
|
+ # Rack AMS units already logged as detected (once per unit).
|
|
|
+ self._rack_ams_logged: set[int] = set()
|
|
|
+ self._tray_layout_key: tuple | None = None
|
|
|
+ self._tray_layout = TrayLayout()
|
|
|
+
|
|
|
# Why the last connection attempt was refused by the printer, or None
|
|
|
# when we have never seen a CONNACK failure since the last success.
|
|
|
# Without this a rejected access code was completely invisible: paho
|
|
|
@@ -2375,6 +2506,7 @@ class BambuMQTTClient:
|
|
|
print_data.get("ams_mapping"),
|
|
|
print_data.get("ams_mapping2"),
|
|
|
self._is_dual_nozzle or is_dual_nozzle_model(self.model),
|
|
|
+ self.tray_layout,
|
|
|
)
|
|
|
|
|
|
@staticmethod
|
|
|
@@ -2593,6 +2725,8 @@ class BambuMQTTClient:
|
|
|
# external slot with id=255 in vir_slot, but tray_now=254 when active.
|
|
|
# Remap id=255→254 for single-slot printers so active detection works.
|
|
|
# Dual-nozzle (H2D) has 2 slots: id=254 (Ext-L) and id=255 (Ext-R).
|
|
|
+ # A nozzle-rack printer (H3C) reports one input per hotend,
|
|
|
+ # 247-255, kept as reported.
|
|
|
if len(vir_slot) == 1 and str(vir_slot[0].get("id", "")) == "255":
|
|
|
vir_slot[0]["id"] = "254"
|
|
|
self.state.raw_data["vt_tray"] = vir_slot
|
|
|
@@ -2847,6 +2981,8 @@ class BambuMQTTClient:
|
|
|
if not isinstance(module, dict):
|
|
|
continue
|
|
|
name = module.get("name", "")
|
|
|
+ if name == AMS3H_MODULE_NAME:
|
|
|
+ name = f"{name}/{self._ams3h_unit_id()}"
|
|
|
if not any(name.startswith(prefix) for prefix in _AMS_MODULE_PREFIXES):
|
|
|
continue
|
|
|
try:
|
|
|
@@ -3181,17 +3317,7 @@ class BambuMQTTClient:
|
|
|
if not isinstance(mapping_raw, list) or not mapping_raw:
|
|
|
return None
|
|
|
|
|
|
- candidates: dict[int, int] = {} # global tray ID -> AMS unit
|
|
|
- for value in mapping_raw:
|
|
|
- if not isinstance(value, int) or value >= 65535:
|
|
|
- continue
|
|
|
- ams_hw_id = value >> 8
|
|
|
- slot = value & 0xFF
|
|
|
- if 0 <= ams_hw_id <= 3 and (slot & 0x03) == local_slot:
|
|
|
- candidates[ams_hw_id * 4 + local_slot] = ams_hw_id
|
|
|
- elif 128 <= ams_hw_id <= 135 and local_slot == 0:
|
|
|
- candidates[ams_hw_id] = ams_hw_id
|
|
|
-
|
|
|
+ candidates = mapped_trays_with_slot(mapping_raw, local_slot) # global tray ID -> AMS unit
|
|
|
if len(candidates) > 1 and units:
|
|
|
candidates = {tray: unit for tray, unit in candidates.items() if unit in units}
|
|
|
if len(candidates) == 1:
|
|
|
@@ -3259,6 +3385,62 @@ class BambuMQTTClient:
|
|
|
)
|
|
|
self._has_a2l_am_unit = True
|
|
|
|
|
|
+ @property
|
|
|
+ def tray_layout(self) -> TrayLayout:
|
|
|
+ """How this printer numbers its trays (utils.tray_ids); rebuilt only when its units change."""
|
|
|
+ raw = self.state.raw_data
|
|
|
+ units, vt_trays = raw.get("ams"), raw.get("vt_tray")
|
|
|
+ key = (
|
|
|
+ tuple((u.get("id"), u.get("info")) for u in units if isinstance(u, dict))
|
|
|
+ if isinstance(units, list)
|
|
|
+ else (),
|
|
|
+ tuple(vt.get("id") for vt in vt_trays if isinstance(vt, dict)) if isinstance(vt_trays, list) else (),
|
|
|
+ self.model,
|
|
|
+ )
|
|
|
+ if key != self._tray_layout_key:
|
|
|
+ from backend.app.utils.printer_models import has_ams3h
|
|
|
+
|
|
|
+ self._tray_layout = TrayLayout.from_state(self.state, has_ams3h(self.model))
|
|
|
+ self._tray_layout_key = key
|
|
|
+ return self._tray_layout
|
|
|
+
|
|
|
+ def _layout_with(self, ams_list: object) -> TrayLayout:
|
|
|
+ """``tray_layout`` including the units of a push not merged into the state yet."""
|
|
|
+ if not isinstance(ams_list, list) or not any(isinstance(u, dict) for u in ams_list):
|
|
|
+ return self.tray_layout
|
|
|
+ units = [u for u in ams_list if isinstance(u, dict)]
|
|
|
+ known = self.state.raw_data.get("ams")
|
|
|
+ if isinstance(known, list):
|
|
|
+ units.extend(u for u in known if isinstance(u, dict))
|
|
|
+ vt_trays = self.state.raw_data.get("vt_tray")
|
|
|
+ return TrayLayout.from_units(units, vt_trays if isinstance(vt_trays, list) else None)
|
|
|
+
|
|
|
+ def _note_rack_ams(self, layout: TrayLayout) -> None:
|
|
|
+ """Log a rack AMS the first time this connection sees it."""
|
|
|
+ for ams_id, kind in layout.rack_units:
|
|
|
+ if ams_id in self._rack_ams_logged:
|
|
|
+ continue
|
|
|
+ self._rack_ams_logged.add(ams_id)
|
|
|
+ logger.info(
|
|
|
+ "[%s] Rack AMS detected: unit %s (AMS type %s), global tray ids %s-%s",
|
|
|
+ self.serial_number,
|
|
|
+ ams_id,
|
|
|
+ int(kind),
|
|
|
+ global_tray_id(ams_id, 0, kind),
|
|
|
+ global_tray_id(ams_id, RACK_SLOT_COUNT - 1, kind),
|
|
|
+ )
|
|
|
+
|
|
|
+ def _ams3h_unit_id(self) -> int:
|
|
|
+ """The unit id of this printer's AMS 3H (32 on the H3C)."""
|
|
|
+ return next((uid for uid, kind in self.tray_layout.rack_units if kind == AmsType.ZMULTI_A), AMS3H_UNIT_ID)
|
|
|
+
|
|
|
+ def _rack_ams_type(self, ams_id: int) -> AmsType | None:
|
|
|
+ """The type of unit ``ams_id`` when it is one of this printer's rack AMS units, else None."""
|
|
|
+ for uid, kind in self.tray_layout.rack_units:
|
|
|
+ if uid == ams_id:
|
|
|
+ return kind
|
|
|
+ return None
|
|
|
+
|
|
|
def _parse_fila_switch(self, data: dict) -> None:
|
|
|
"""Read the Filament Track Switch block out of a print payload — #1162.
|
|
|
|
|
|
@@ -3323,37 +3505,15 @@ class BambuMQTTClient:
|
|
|
continue
|
|
|
if ext_id <= 1:
|
|
|
snow_read.add(ext_id)
|
|
|
- # Normalize H2D snow value to global tray ID
|
|
|
- ams_id = snow >> 8
|
|
|
- slot = snow & 0xFF
|
|
|
- if 0 <= ams_id <= 3:
|
|
|
- # Regular AMS slot
|
|
|
- global_tray = ams_id * 4 + (slot & 0x03)
|
|
|
- old_val = self.state.h2d_extruder_snow.get(ext_id)
|
|
|
- if old_val != global_tray:
|
|
|
- logger.debug(
|
|
|
- f"[{self.serial_number}] H2D extruder[{ext_id}] snow: "
|
|
|
- f"raw={snow} (AMS {ams_id} slot {slot}) -> global tray {global_tray}"
|
|
|
- )
|
|
|
- self.state.h2d_extruder_snow[ext_id] = global_tray
|
|
|
- elif ams_id == 254 or ams_id == 255:
|
|
|
- # External spool or unloaded
|
|
|
- normalized = 254 if slot != 255 else 255
|
|
|
- old_val = self.state.h2d_extruder_snow.get(ext_id)
|
|
|
- if old_val != normalized:
|
|
|
- logger.debug(
|
|
|
- f"[{self.serial_number}] H2D extruder[{ext_id}] snow: "
|
|
|
- f"raw={snow} -> {'external' if normalized == 254 else 'unloaded'}"
|
|
|
- )
|
|
|
- self.state.h2d_extruder_snow[ext_id] = normalized
|
|
|
- elif 128 <= ams_id <= 135:
|
|
|
- # External spool with hub mapping
|
|
|
- old_val = self.state.h2d_extruder_snow.get(ext_id)
|
|
|
- if old_val != ams_id:
|
|
|
- logger.debug(
|
|
|
- f"[{self.serial_number}] H2D extruder[{ext_id}] snow: raw={snow} -> external hub {ams_id}"
|
|
|
- )
|
|
|
- self.state.h2d_extruder_snow[ext_id] = ams_id
|
|
|
+ # Normalize H2D snow value to global tray ID (255 = unloaded)
|
|
|
+ global_tray = 255 if snow == 0xFFFF else decode_wire_slot(snow)
|
|
|
+ if global_tray is None:
|
|
|
+ continue
|
|
|
+ if self.state.h2d_extruder_snow.get(ext_id) != global_tray:
|
|
|
+ logger.debug(
|
|
|
+ f"[{self.serial_number}] H2D extruder[{ext_id}] snow: raw={snow} -> global tray {global_tray}"
|
|
|
+ )
|
|
|
+ self.state.h2d_extruder_snow[ext_id] = global_tray
|
|
|
return self.state.active_extruder in snow_read
|
|
|
|
|
|
def _parse_extruder_slots(self, data: dict) -> None:
|
|
|
@@ -3409,6 +3569,7 @@ class BambuMQTTClient:
|
|
|
if "ams" in ams_data:
|
|
|
ams_list = ams_data["ams"]
|
|
|
self._normalize_a2l_am_units(ams_list)
|
|
|
+ apply_rack_ams_zone_fields(ams_list, dict(self.tray_layout.units))
|
|
|
# Log all AMS dict fields to debug tray_now for H2D dual-nozzle
|
|
|
non_list_fields = {k: v for k, v in ams_data.items() if k != "ams"}
|
|
|
if non_list_fields:
|
|
|
@@ -3490,7 +3651,16 @@ class BambuMQTTClient:
|
|
|
# Single-nozzle printers with multiple AMS (e.g. P2S) also report local slot IDs (#420)
|
|
|
# — disambiguated below using MQTT mapping field
|
|
|
ams_map = self.state.ams_extruder_map
|
|
|
- if self._is_dual_nozzle and 0 <= parsed_tray_now <= 3:
|
|
|
+ # A rack AMS reports tray_now as its raw slot 0-8 (global 28 +
|
|
|
+ # slot). Its units may arrive in this same push, before the merge.
|
|
|
+ rack_layout = self._layout_with(ams_list)
|
|
|
+ self._note_rack_ams(rack_layout)
|
|
|
+ if rack_layout.rack_units and not self._is_dual_nozzle:
|
|
|
+ rack_tray = rack_layout.raw_slot_to_global(parsed_tray_now)
|
|
|
+ # 255 (nothing on the toolhead, also mid-swap) and any
|
|
|
+ # external input id are kept as reported.
|
|
|
+ self.state.tray_now = rack_tray if rack_tray is not None else parsed_tray_now
|
|
|
+ elif self._is_dual_nozzle and 0 <= parsed_tray_now <= 3:
|
|
|
# First, check if we have a pending target that matches this slot
|
|
|
pending_target = self.state.pending_tray_target
|
|
|
pending_at = self.state.pending_tray_target_at
|
|
|
@@ -3509,7 +3679,7 @@ class BambuMQTTClient:
|
|
|
if pending_target is not None:
|
|
|
# Same slot rule as the snow branch below: an AMS-HT
|
|
|
# (128-135) has its one tray in slot 0.
|
|
|
- pending_slot = 0 if 128 <= pending_target <= 135 else pending_target % 4
|
|
|
+ pending_slot = 0 if is_ams_ht_id(pending_target) else pending_target % 4
|
|
|
if pending_slot == parsed_tray_now:
|
|
|
# Slot matches our pending target - use the full global ID
|
|
|
logger.debug(
|
|
|
@@ -3541,7 +3711,7 @@ class BambuMQTTClient:
|
|
|
if snow_tray is None
|
|
|
else snow_tray % 4
|
|
|
if snow_tray < 128
|
|
|
- else (0 if snow_tray <= 135 else -1)
|
|
|
+ else (0 if is_ams_ht_id(snow_tray) else -1)
|
|
|
)
|
|
|
# A snow from an earlier frame may predate a swap: it
|
|
|
# names the tray only while it agrees with the slot.
|
|
|
@@ -3600,26 +3770,20 @@ class BambuMQTTClient:
|
|
|
elif len(ams_on_extruder) == 1:
|
|
|
# Single AMS on this extruder - unambiguous
|
|
|
active_ams_id = ams_on_extruder[0]
|
|
|
- if 128 <= active_ams_id <= 135:
|
|
|
- # AMS-HT: single slot per unit, global ID = unit ID
|
|
|
- global_tray_id = active_ams_id
|
|
|
- else:
|
|
|
- global_tray_id = active_ams_id * 4 + parsed_tray_now
|
|
|
+ # AMS-HT: single slot per unit, global ID = unit ID
|
|
|
+ fallback_tray = global_tray_id(active_ams_id, parsed_tray_now)
|
|
|
logger.debug(
|
|
|
f"[{self.serial_number}] H2D tray_now fallback: "
|
|
|
- f"slot {parsed_tray_now} + single AMS {active_ams_id} -> global ID {global_tray_id}"
|
|
|
+ f"slot {parsed_tray_now} + single AMS {active_ams_id} -> global ID {fallback_tray}"
|
|
|
)
|
|
|
- self.state.tray_now = global_tray_id
|
|
|
+ self.state.tray_now = fallback_tray
|
|
|
elif len(ams_on_extruder) > 1:
|
|
|
# Multiple AMS on this extruder - keep current if valid, else try to narrow down
|
|
|
current_tray = self.state.tray_now
|
|
|
# Determine which AMS unit and slot the current tray belongs to
|
|
|
- if 0 <= current_tray <= 15:
|
|
|
- current_ams = current_tray // 4
|
|
|
- current_slot = current_tray % 4
|
|
|
- elif 128 <= current_tray <= 135:
|
|
|
- current_ams = current_tray # AMS-HT: ID = tray ID
|
|
|
- current_slot = 0
|
|
|
+ if 0 <= current_tray <= 15 or is_ams_ht_id(current_tray):
|
|
|
+ # AMS-HT: ID = tray ID, one slot
|
|
|
+ current_ams, current_slot = rack_layout.slot_key(current_tray)
|
|
|
else:
|
|
|
current_ams = -1
|
|
|
current_slot = -1
|
|
|
@@ -3637,7 +3801,7 @@ class BambuMQTTClient:
|
|
|
candidates = ams_on_extruder
|
|
|
if len(candidates) == 1:
|
|
|
cand = candidates[0]
|
|
|
- resolved = cand if 128 <= cand <= 135 else cand * 4 + parsed_tray_now
|
|
|
+ resolved = global_tray_id(cand, parsed_tray_now)
|
|
|
logger.debug(
|
|
|
f"[{self.serial_number}] H2D tray_now: multiple AMS {ams_on_extruder}, "
|
|
|
f"narrowed to AMS {cand} -> global ID {resolved}"
|
|
|
@@ -3740,15 +3904,8 @@ class BambuMQTTClient:
|
|
|
self.state.tray_now = parsed_tray_now
|
|
|
|
|
|
# Track last valid tray for usage tracking (survives retract → 255 at print end)
|
|
|
- # Valid physical trays: 0-15 (regular AMS), 24-27 (A2L AMS-Lite,
|
|
|
- # normalised unit 6), 128-135 (AMS-HT), 254 (external spool)
|
|
|
tn = self.state.tray_now
|
|
|
- if (
|
|
|
- (0 <= tn <= 15)
|
|
|
- or (A2L_LITE_GLOBAL_BASE <= tn <= A2L_LITE_GLOBAL_BASE + 3)
|
|
|
- or (128 <= tn <= 135)
|
|
|
- or tn == 254
|
|
|
- ):
|
|
|
+ if rack_layout.is_loaded_tray(tn):
|
|
|
# Log tray change for mid-print usage splitting. Gate on the
|
|
|
# print-lifecycle flags (`_was_running` set on first RUNNING /
|
|
|
# new print, `_completion_triggered` set when on_print_complete
|
|
|
@@ -3787,6 +3944,7 @@ class BambuMQTTClient:
|
|
|
elif isinstance(ams_data, list):
|
|
|
ams_list = ams_data
|
|
|
self._normalize_a2l_am_units(ams_list)
|
|
|
+ apply_rack_ams_zone_fields(ams_list, dict(self.tray_layout.units))
|
|
|
else:
|
|
|
logger.warning("[%s] Unexpected AMS data format: %s", self.serial_number, type(ams_data))
|
|
|
return
|
|
|
@@ -3984,9 +4142,14 @@ class BambuMQTTClient:
|
|
|
inlet_moves: list[tuple[int, str]] = []
|
|
|
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 {}
|
|
|
+ # A rack AMS feeds the one extruder of a rack printer; listed here it
|
|
|
+ # would make the slot/dispatch code assume two nozzles.
|
|
|
+ rack_ams_ids = {uid for uid, _ in TrayLayout.from_units(merged_ams).rack_units}
|
|
|
for ams_unit in merged_ams:
|
|
|
ams_id = ams_unit.get("id")
|
|
|
info = ams_unit.get("info")
|
|
|
+ if _mqtt_int(ams_id) in rack_ams_ids:
|
|
|
+ continue
|
|
|
if ams_id is not None and info is not None:
|
|
|
try:
|
|
|
# info is a hex-encoded string in MQTT JSON (e.g. "10001003")
|
|
|
@@ -4332,7 +4495,7 @@ class BambuMQTTClient:
|
|
|
"""
|
|
|
raw = self.state.raw_data or {}
|
|
|
if ams_id == 255:
|
|
|
- want_ext = 254 + tray_id
|
|
|
+ want_ext = external_tray_id(tray_id)
|
|
|
for vt in raw.get("vt_tray", []) or []:
|
|
|
if isinstance(vt, dict) and str(vt.get("id")) == str(want_ext):
|
|
|
return vt
|
|
|
@@ -5705,6 +5868,8 @@ class BambuMQTTClient:
|
|
|
# H2C uses "fila_id", H2D uses "filament_id"
|
|
|
"filament_id": str(n.get("filament_id") or n.get("fila_id", "")),
|
|
|
"filament_type": str(n.get("tray_type", "") or n.get("filament_type", "")),
|
|
|
+ # H3C: home rack position 0-8 (the mounted hotend reports id 0)
|
|
|
+ "addr_id": n.get("addr_id") if isinstance(n.get("addr_id"), int) else None,
|
|
|
}
|
|
|
for i, n in enumerate(nozzle_info)
|
|
|
],
|
|
|
@@ -5917,12 +6082,7 @@ class BambuMQTTClient:
|
|
|
# Seeded with the assigned spools by usage_tracker.on_print_start.
|
|
|
self.state.spool_change_log = []
|
|
|
tn = self.state.tray_now
|
|
|
- if (
|
|
|
- (0 <= tn <= 15)
|
|
|
- or (A2L_LITE_GLOBAL_BASE <= tn <= A2L_LITE_GLOBAL_BASE + 3)
|
|
|
- or (128 <= tn <= 135)
|
|
|
- or tn == 254
|
|
|
- ):
|
|
|
+ if self.tray_layout.is_loaded_tray(tn):
|
|
|
self.state.tray_change_log.append((tn, 0))
|
|
|
# Initialize timelapse tracking based on current state
|
|
|
# NOTE: xcam data is parsed BEFORE this code runs in _process_message,
|
|
|
@@ -6052,6 +6212,11 @@ class BambuMQTTClient:
|
|
|
self._timelapse_during_print = False # Reset for next print
|
|
|
# Include HMS errors for failure reason detection
|
|
|
hms_errors_data = [e.completion_payload() for e in self.state.hms_errors or []]
|
|
|
+ from backend.app.utils.printer_models import usage_progress
|
|
|
+
|
|
|
+ last_progress = usage_progress(
|
|
|
+ self.model, self._last_valid_progress, self._last_valid_layer_num, self.state.total_layers
|
|
|
+ )
|
|
|
self.on_print_complete(
|
|
|
{
|
|
|
"status": status,
|
|
|
@@ -6063,7 +6228,7 @@ class BambuMQTTClient:
|
|
|
"hms_errors": hms_errors_data,
|
|
|
"ams_mapping": self._captured_ams_mapping,
|
|
|
# Last valid progress/layer before firmware reset (for partial usage tracking)
|
|
|
- "last_progress": self._last_valid_progress,
|
|
|
+ "last_progress": last_progress,
|
|
|
"last_layer_num": self._last_valid_layer_num,
|
|
|
}
|
|
|
)
|
|
|
@@ -6431,14 +6596,26 @@ class BambuMQTTClient:
|
|
|
# Passing raw 254/255 in the flat array causes H2D firmware to fail
|
|
|
# with 0700_8012 "Failed to get AMS mapping table".
|
|
|
flat_ams_mapping = []
|
|
|
+ layout = self.tray_layout
|
|
|
if ams_mapping is not None:
|
|
|
for tray_id in ams_mapping:
|
|
|
# Ensure tray_id is an integer (may be string from JSON)
|
|
|
tray_id = int(tray_id) if tray_id is not None else -1
|
|
|
+ rack_slot = layout.rack_slot(tray_id) if tray_id >= 0 else None
|
|
|
if tray_id == -1:
|
|
|
# Unmapped filament slot
|
|
|
flat_ams_mapping.append(-1)
|
|
|
ams_mapping2.append({"ams_id": 255, "slot_id": 255})
|
|
|
+ elif layout.rack and layout.is_external(tray_id):
|
|
|
+ # A rack printer's per-hotend input 247-255, as Studio
|
|
|
+ # sends it: -1 flat, its own id in ams_mapping2.
|
|
|
+ flat_ams_mapping.append(-1)
|
|
|
+ ams_mapping2.append({"ams_id": tray_id, "slot_id": 0})
|
|
|
+ elif rack_slot is not None:
|
|
|
+ # Rack AMS slot, as Studio sends it: 28 + k flat,
|
|
|
+ # {ams_id: 32, slot_id: k} in ams_mapping2.
|
|
|
+ flat_ams_mapping.append(tray_id)
|
|
|
+ ams_mapping2.append({"ams_id": rack_slot[0], "slot_id": rack_slot[1]})
|
|
|
elif tray_id >= 254:
|
|
|
# External/virtual spool. BambuStudio convention:
|
|
|
# 255 = VIRTUAL_TRAY_MAIN_ID (main/right nozzle)
|
|
|
@@ -6457,7 +6634,7 @@ class BambuMQTTClient:
|
|
|
# AMS-HT: global tray ID IS the ams_id (single tray per unit)
|
|
|
flat_ams_mapping.append(tray_id)
|
|
|
ams_mapping2.append({"ams_id": tray_id, "slot_id": 0})
|
|
|
- elif (_a2l := a2l_lite_wire_ids(tray_id // 4, tray_id)) is not None:
|
|
|
+ elif (_a2l := a2l_lite_wire_ids(layout.slot_key(tray_id)[0], tray_id)) is not None:
|
|
|
# A2L AMS-Lite (normalised global 24-27): flat mapping is the
|
|
|
# LOCAL slot 0-3 and ams_mapping2 carries {ams_id:16, slot_id:0-3}
|
|
|
# — both CONFIRMED against the firmware's own mapping
|
|
|
@@ -6467,8 +6644,7 @@ class BambuMQTTClient:
|
|
|
ams_mapping2.append({"ams_id": _wire_ams, "slot_id": _wire_slot})
|
|
|
else:
|
|
|
# Regular AMS tray: Global tray ID = (ams_id * 4) + slot_id
|
|
|
- ams_id = tray_id // 4
|
|
|
- slot_id = tray_id % 4
|
|
|
+ ams_id, slot_id = layout.slot_key(tray_id)
|
|
|
flat_ams_mapping.append(tray_id)
|
|
|
ams_mapping2.append({"ams_id": ams_id, "slot_id": slot_id})
|
|
|
|
|
|
@@ -6503,8 +6679,10 @@ class BambuMQTTClient:
|
|
|
# (or is recomputed upstream) instead of silently going external, and
|
|
|
# never gets force-enabled by (a) either.
|
|
|
if ams_mapping and not is_dual_nozzle:
|
|
|
- has_real_tray = any(t is not None and 0 <= int(t) <= 253 for t in ams_mapping)
|
|
|
- all_external = all(t is None or int(t) >= 254 for t in ams_mapping)
|
|
|
+ has_real_tray = any(
|
|
|
+ t is not None and 0 <= int(t) <= 253 and not layout.is_external(int(t)) for t in ams_mapping
|
|
|
+ )
|
|
|
+ all_external = all(t is None or int(t) >= 254 or layout.is_external(int(t)) for t in ams_mapping)
|
|
|
# (Not when the scheduler worked out from the plate's own
|
|
|
# filament list that it prints only from the spool holder: a
|
|
|
# real tray here is then one another plate of the file uses.)
|
|
|
@@ -7026,6 +7204,11 @@ class BambuMQTTClient:
|
|
|
logger.exception("[%s] publish_raw failed for topic=%s", self.serial_number, topic)
|
|
|
return False
|
|
|
|
|
|
+ def _ams3h_drying_zone_id(self, ams_id: int) -> int:
|
|
|
+ """The drying zone id of AMS 3H unit ``ams_id`` from the last status, else 0."""
|
|
|
+ unit = self._ams_unit(ams_id)
|
|
|
+ return _mqtt_int((unit or {}).get("dry_zone_id")) or 0
|
|
|
+
|
|
|
def send_drying_command(
|
|
|
self, ams_id: int, temp: int, duration: int, mode: int = 1, filament: str = "", rotate_tray: bool = False
|
|
|
):
|
|
|
@@ -7047,6 +7230,7 @@ class BambuMQTTClient:
|
|
|
# _drying_targets dict below stays keyed by the normalised id so the
|
|
|
# on_drying_complete callback matches the telemetry.
|
|
|
wire_ams_id = a2l_lite_wire_ids(ams_id, 0)[0] if ams_id == A2L_LITE_NORMALIZED_AMS_ID else ams_id
|
|
|
+ ams3h = self._rack_ams_type(ams_id) == AmsType.ZMULTI_A
|
|
|
command = {
|
|
|
"print": {
|
|
|
"sequence_id": str(self._sequence_id),
|
|
|
@@ -7062,6 +7246,14 @@ class BambuMQTTClient:
|
|
|
"close_power_conflict": False,
|
|
|
}
|
|
|
}
|
|
|
+ if ams3h:
|
|
|
+ # As Studio 02.09 sends it (DevFilaSystemCtrl.cpp): a start names
|
|
|
+ # the drying zone and cooling_temp; a stop names no zone. The AMS 3H
|
|
|
+ # can't turn spools.
|
|
|
+ command["print"]["rotate_tray"] = False
|
|
|
+ if mode == 1:
|
|
|
+ command["print"]["zone_id"] = self._ams3h_drying_zone_id(ams_id)
|
|
|
+ command["print"]["cooling_temp"] = ams3h_drying_cooling_temp(filament)
|
|
|
# Log the full wire JSON at INFO so support bundles capture exactly
|
|
|
# what we sent — needed to diagnose silent rejections (#1447) where
|
|
|
# the printer ACKs the command but never starts/stops drying.
|
|
|
@@ -7804,6 +7996,16 @@ class BambuMQTTClient:
|
|
|
Returns:
|
|
|
True if command was sent, False otherwise
|
|
|
"""
|
|
|
+ # The H3C's heater only runs in the airduct's heating mode.
|
|
|
+ from backend.app.utils.printer_models import chamber_heater_needs_heating_mode
|
|
|
+
|
|
|
+ if target > 0 and self.state.airduct_mode != 1 and chamber_heater_needs_heating_mode(self.model):
|
|
|
+ logger.info(
|
|
|
+ "[%s] Chamber target %s°C: switching the airduct to heating mode first (H3C)",
|
|
|
+ self.serial_number,
|
|
|
+ target,
|
|
|
+ )
|
|
|
+ self.set_airduct_mode("heating")
|
|
|
# M141 sets chamber temperature
|
|
|
result = self.send_gcode(f"M141 S{target}")
|
|
|
# Track chamber target locally (MQTT reports encoded values that need filtering)
|
|
|
@@ -7953,6 +8155,87 @@ class BambuMQTTClient:
|
|
|
)
|
|
|
return True
|
|
|
|
|
|
+ def _ams_unit(self, ams_id: int) -> dict | None:
|
|
|
+ """AMS unit ``ams_id`` in the last status, or None."""
|
|
|
+ for unit in self.state.raw_data.get("ams") or []:
|
|
|
+ if isinstance(unit, dict) and _mqtt_int(unit.get("id")) == ams_id:
|
|
|
+ return unit
|
|
|
+ return None
|
|
|
+
|
|
|
+ def _rack_tray(self, tray_id: int | None) -> tuple[int, int, dict | None] | None:
|
|
|
+ """``(ams_id, slot, tray)`` of a rack AMS slot's global tray id (the AMS 3H's 28-36), else None."""
|
|
|
+ if not isinstance(tray_id, int):
|
|
|
+ return None
|
|
|
+ located = self.tray_layout.rack_slot(tray_id)
|
|
|
+ if located is None:
|
|
|
+ return None
|
|
|
+ ams_id, slot = located
|
|
|
+ unit = self._ams_unit(ams_id) or {}
|
|
|
+ tray = next(
|
|
|
+ (t for t in unit.get("tray") or [] if isinstance(t, dict) and _mqtt_int(t.get("id")) == slot),
|
|
|
+ None,
|
|
|
+ )
|
|
|
+ return ams_id, slot, tray
|
|
|
+
|
|
|
+ def rack_tray_has_filament(self, tray_id: int) -> bool:
|
|
|
+ """Whether rack AMS slot ``tray_id`` reports a filament type, which Studio requires before a Load.
|
|
|
+
|
|
|
+ The AMS 3H registers a spool only once it has pulled the filament in at the inlet.
|
|
|
+ """
|
|
|
+ rack = self._rack_tray(tray_id)
|
|
|
+ return bool(rack and rack[2] and rack[2].get("tray_type"))
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _tray_mid_temp(tray: dict | None) -> int:
|
|
|
+ """Midpoint of a tray's nozzle_temp_min/max, or -1 (as Studio's ams_change_filament)."""
|
|
|
+ if tray is None:
|
|
|
+ return -1
|
|
|
+ low, high = _mqtt_int(tray.get("nozzle_temp_min")), _mqtt_int(tray.get("nozzle_temp_max"))
|
|
|
+ return (low + high) // 2 if low and high and low > 0 and high > 0 else -1
|
|
|
+
|
|
|
+ def _rack_change_filament(self, tray_id: int, load: bool) -> bool:
|
|
|
+ """Load AMS 3H slot ``tray_id`` into its own hotend, or unload that hotend.
|
|
|
+
|
|
|
+ As Studio 02.09 sends it for a direct-connected rack (PageFilament.cpp):
|
|
|
+ load targets 32 at slot k, unload 255 at slot 255 - k (the feeder to
|
|
|
+ retract). The AMS 3H can't rewind: an unload ends with a hand pull.
|
|
|
+ """
|
|
|
+ client = self._client
|
|
|
+ rack = self._rack_tray(tray_id)
|
|
|
+ if client is None or rack is None:
|
|
|
+ return False
|
|
|
+ ams_id, slot, tray = rack
|
|
|
+ on_head = self._rack_tray(self.state.tray_now)
|
|
|
+ self._sequence_id += 1
|
|
|
+ command = {
|
|
|
+ "print": {
|
|
|
+ "command": "ams_change_filament",
|
|
|
+ "sequence_id": str(self._sequence_id),
|
|
|
+ "ams_id": ams_id,
|
|
|
+ "target": ams_id if load else 255,
|
|
|
+ "slot_id": slot if load else 255 - slot,
|
|
|
+ "extruder_id": 0,
|
|
|
+ "curr_temp": self._tray_mid_temp(on_head[2] if on_head else None),
|
|
|
+ "tar_temp": self._tray_mid_temp(tray),
|
|
|
+ }
|
|
|
+ }
|
|
|
+ command_json = json.dumps(command)
|
|
|
+ logger.info(
|
|
|
+ "[%s] Publishing ams_change_filament (%s, AMS 3H slot %d): %s",
|
|
|
+ self.serial_number,
|
|
|
+ "load" if load else "unload",
|
|
|
+ slot + 1,
|
|
|
+ command_json,
|
|
|
+ )
|
|
|
+ client.publish(self.topic_publish, command_json, qos=1)
|
|
|
+ if load:
|
|
|
+ self.state.pending_tray_target = tray_id
|
|
|
+ self.state.pending_tray_target_at = time.monotonic()
|
|
|
+ else:
|
|
|
+ self.state.pending_tray_target = None
|
|
|
+ self.state.pending_tray_target_at = None
|
|
|
+ return True
|
|
|
+
|
|
|
def ams_load_filament(self, tray_id: int, extruder_id: int | None = None) -> bool:
|
|
|
"""Load filament from a specific AMS tray.
|
|
|
|
|
|
@@ -7978,6 +8261,17 @@ class BambuMQTTClient:
|
|
|
if not self._client or not self.state.connected:
|
|
|
logger.warning("[%s] Cannot load filament: not connected", self.serial_number)
|
|
|
return False
|
|
|
+ if self.tray_layout.rack_units:
|
|
|
+ # Only an AMS 3H slot can be loaded: an external input or an AMS-B
|
|
|
+ # can't feed itself (Studio's CanAutoFeed).
|
|
|
+ rack = self._rack_tray(tray_id)
|
|
|
+ if rack is None or self.tray_layout.ams_type(rack[0]) != AmsType.ZMULTI_A:
|
|
|
+ logger.warning("[%s] Load: tray %s is not an AMS 3H slot", self.serial_number, tray_id)
|
|
|
+ return False
|
|
|
+ if not self.rack_tray_has_filament(tray_id):
|
|
|
+ logger.warning("[%s] Load: AMS 3H slot %d has no filament", self.serial_number, rack[1] + 1)
|
|
|
+ return False
|
|
|
+ return self._rack_change_filament(tray_id, load=True)
|
|
|
|
|
|
# Build the ams_change_filament command. Encoding differs by target type:
|
|
|
# - AMS slots (0..15, A2L 24..27, AMS-HT 128..135): see
|
|
|
@@ -8005,7 +8299,7 @@ class BambuMQTTClient:
|
|
|
else:
|
|
|
# A regular AMS, the A2L AMS-Lite (physical unit 16) or an AMS-HT
|
|
|
# (128-135), as BambuStudio addresses each.
|
|
|
- ams_id, slot_id, wire_target = change_filament_wire_ids(tray_id)
|
|
|
+ ams_id, slot_id, wire_target = change_filament_wire_ids(tray_id, self.tray_layout)
|
|
|
curr_temp = -1
|
|
|
tar_temp = -1
|
|
|
|
|
|
@@ -8058,6 +8352,13 @@ class BambuMQTTClient:
|
|
|
if not self._client or not self.state.connected:
|
|
|
logger.warning("[%s] Cannot unload filament: not connected", self.serial_number)
|
|
|
return False
|
|
|
+ if self.tray_layout.rack_units:
|
|
|
+ # The hotend of the named AMS 3H slot, else of the one on the toolhead.
|
|
|
+ source = tray_id if tray_id is not None else self.state.tray_now
|
|
|
+ if self._rack_tray(source) is None:
|
|
|
+ logger.warning("[%s] Unload: no AMS 3H slot to unload (tray %s)", self.serial_number, source)
|
|
|
+ return False
|
|
|
+ return self._rack_change_filament(source, load=False)
|
|
|
|
|
|
# Get the currently loaded tray info
|
|
|
tray_now = self.state.tray_now
|
|
|
@@ -8069,7 +8370,7 @@ class BambuMQTTClient:
|
|
|
ams_id = 255 # No filament or external spool
|
|
|
else:
|
|
|
# Source AMS: the A2L AMS-Lite as physical 16, an AMS-HT as its unit id.
|
|
|
- ams_id = change_filament_wire_ids(source_tray)[0]
|
|
|
+ ams_id = change_filament_wire_ids(source_tray, self.tray_layout)[0]
|
|
|
|
|
|
# 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
|
|
|
@@ -8092,7 +8393,7 @@ class BambuMQTTClient:
|
|
|
# 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 = change_filament_wire_ids(tray_id)[1]
|
|
|
+ local_slot = change_filament_wire_ids(tray_id, self.tray_layout)[1]
|
|
|
holder = next(
|
|
|
(ext for ext, slot in self.state.extruder_slots.items() if slot.holds(ams_id, local_slot)),
|
|
|
None,
|
|
|
@@ -8194,11 +8495,13 @@ class BambuMQTTClient:
|
|
|
tray_now = self.state.tray_now
|
|
|
if tray_now != 255:
|
|
|
# Decode which tray is loaded for the message
|
|
|
+ layout = self.tray_layout
|
|
|
if tray_now == 254:
|
|
|
loaded_tray = "external spool"
|
|
|
+ elif (rack := layout.rack_slot(tray_now)) is not None:
|
|
|
+ loaded_tray = f"{unit_label(rack[0], layout.ams_type(rack[0]))} slot {rack[1] + 1}"
|
|
|
elif tray_now >= 0 and tray_now < 128:
|
|
|
- loaded_ams = tray_now // 4
|
|
|
- loaded_slot = tray_now % 4
|
|
|
+ loaded_ams, loaded_slot = layout.slot_key(tray_now)
|
|
|
loaded_tray = f"AMS {loaded_ams + 1} slot {loaded_slot + 1}"
|
|
|
else:
|
|
|
loaded_tray = f"tray {tray_now}"
|
|
|
@@ -8234,10 +8537,10 @@ class BambuMQTTClient:
|
|
|
"[%s] ams_get_rfid refused (%s), falling back to M620 R%s",
|
|
|
self.serial_number,
|
|
|
refused,
|
|
|
- wire_ams_id * 4 + wire_slot_id,
|
|
|
+ legacy_tray_index(wire_ams_id, wire_slot_id),
|
|
|
)
|
|
|
legacy_refused = await self._send_rfid_command(
|
|
|
- {"command": "gcode_line", "param": f"M620 R{wire_ams_id * 4 + wire_slot_id}\n"}
|
|
|
+ {"command": "gcode_line", "param": f"M620 R{legacy_tray_index(wire_ams_id, wire_slot_id)}\n"}
|
|
|
)
|
|
|
if legacy_refused is None:
|
|
|
return True, f"Refreshing AMS {ams_id} tray {tray_id} (legacy command)"
|
|
|
@@ -8325,7 +8628,7 @@ class BambuMQTTClient:
|
|
|
# (254=ext-L / slot 0, 255=ext-R / slot 1). The dual case is NOT
|
|
|
# covered by the X1C capture — left at `mqtt_tray_id = 0` until a
|
|
|
# captured Studio→H2D exchange confirms the correct value.
|
|
|
- mqtt_ams_id = 254 + tray_id
|
|
|
+ mqtt_ams_id = external_tray_id(tray_id)
|
|
|
mqtt_tray_id = 0
|
|
|
else:
|
|
|
# Single external slot (X1C, P1S, A1): global tray_id=254.
|
|
|
@@ -8337,6 +8640,10 @@ class BambuMQTTClient:
|
|
|
# firmware's own ams_mapping2 {ams_id:16, slot_id:0-3}).
|
|
|
mqtt_ams_id, slot_id, _ = _a2l
|
|
|
mqtt_tray_id = slot_id
|
|
|
+ elif self._rack_ams_type(ams_id) is not None:
|
|
|
+ # AMS 3H: unit 32 with its slot 0-8 in tray_id and slot_id (verified).
|
|
|
+ mqtt_ams_id = ams_id
|
|
|
+ mqtt_tray_id = slot_id = tray_id
|
|
|
elif ams_id <= 3:
|
|
|
mqtt_ams_id = ams_id
|
|
|
mqtt_tray_id = tray_id
|
|
|
@@ -8398,7 +8705,7 @@ class BambuMQTTClient:
|
|
|
vt_tray = self.state.raw_data.get("vt_tray", []) if self.state.raw_data else []
|
|
|
if len(vt_tray) > 1:
|
|
|
# Dual external slots (H2D): each ext slot is its own virtual AMS unit
|
|
|
- mqtt_ams_id = 254 + tray_id
|
|
|
+ mqtt_ams_id = external_tray_id(tray_id)
|
|
|
mqtt_tray_id = 0
|
|
|
else:
|
|
|
# Single external slot (X1C, P1S, A1): global tray_id=254.
|
|
|
@@ -8409,6 +8716,10 @@ class BambuMQTTClient:
|
|
|
# A2L AMS-Lite: physical unit 16, local 0-3 slot (matches ams_mapping2).
|
|
|
mqtt_ams_id, slot_id, _ = _a2l
|
|
|
mqtt_tray_id = slot_id
|
|
|
+ elif self._rack_ams_type(ams_id) is not None:
|
|
|
+ # AMS 3H: unit 32 with its slot 0-8 (see ams_set_filament_setting).
|
|
|
+ mqtt_ams_id = ams_id
|
|
|
+ mqtt_tray_id = slot_id = tray_id
|
|
|
elif ams_id <= 3:
|
|
|
mqtt_ams_id = ams_id
|
|
|
mqtt_tray_id = tray_id
|
|
|
@@ -8482,23 +8793,27 @@ class BambuMQTTClient:
|
|
|
if len(vt_tray) > 1:
|
|
|
# Dual external slots (H2D): each ext slot is its own virtual AMS unit
|
|
|
# Confirmed from BambuStudio logs: ext-R sends ams_id=255, tray_id=255
|
|
|
- mqtt_ams_id = 254 + tray_id
|
|
|
- mqtt_tray_id = 254 + tray_id
|
|
|
+ mqtt_ams_id = external_tray_id(tray_id)
|
|
|
+ mqtt_tray_id = external_tray_id(tray_id)
|
|
|
else:
|
|
|
# Single external slot (X1C, P1S, A1): global tray_id=254
|
|
|
mqtt_ams_id = 254
|
|
|
mqtt_tray_id = 254
|
|
|
slot_id = 0
|
|
|
+ elif self._rack_ams_type(ams_id) is not None:
|
|
|
+ # AMS 3H: unit 32, slot k in tray_id and slot_id ({32, 8, 8} verified).
|
|
|
+ mqtt_ams_id = ams_id
|
|
|
+ mqtt_tray_id = slot_id = tray_id
|
|
|
elif ams_id <= 3:
|
|
|
mqtt_ams_id = ams_id
|
|
|
- mqtt_tray_id = ams_id * 4 + tray_id
|
|
|
+ mqtt_tray_id = legacy_tray_index(ams_id, tray_id)
|
|
|
slot_id = tray_id
|
|
|
elif (_a2l := a2l_lite_wire_ids(ams_id, tray_id)) is not None:
|
|
|
# A2L AMS-Lite: physical unit 16 + local slot are confirmed; the GLOBAL
|
|
|
# tray_id this command wants (physical 16*4+slot) is extrapolated (no
|
|
|
# A2L cali_sel capture yet) — see a2l_lite_wire_ids.
|
|
|
mqtt_ams_id, slot_id, mqtt_tray_id = _a2l
|
|
|
- elif ams_id >= 128 and ams_id <= 135:
|
|
|
+ elif is_ams_ht_id(ams_id):
|
|
|
mqtt_ams_id = ams_id
|
|
|
mqtt_tray_id = tray_id
|
|
|
slot_id = 0
|
|
|
@@ -8571,7 +8886,10 @@ class BambuMQTTClient:
|
|
|
# physical global (extrapolated 64-67; see a2l_lite_wire_ids). ams_id
|
|
|
# stays 0 (hardcoded, as for every other unit here).
|
|
|
wire_tray_id = tray_id
|
|
|
- if 0 <= tray_id <= 253 and (_a2l := a2l_lite_wire_ids(tray_id // 4, tray_id)) is not None:
|
|
|
+ if (
|
|
|
+ 0 <= tray_id <= 253
|
|
|
+ and (_a2l := a2l_lite_wire_ids(self.tray_layout.slot_key(tray_id)[0], tray_id)) is not None
|
|
|
+ ):
|
|
|
wire_tray_id = _a2l[2]
|
|
|
|
|
|
filament_entry = {
|