Przeglądaj źródła

fix(a2l): normalise AMS Lite unit 16->6 so slots load and deduct (#a2l-am-unit-16)

The A2L reports its 4-slot AMS Lite as unit id 16, but its slot-presence
bitmasks sit at bit base 24 (id 6) and it reports tray_now as a local 0-3
slot. Fed the raw id 16, the ams_id*4+slot convention probed bits 64-67
(always zero) and marked loaded slots empty; the local tray_now was read as
global, so usage deducted from the wrong spool (or not at all); and the
ams_id<=7 DB constraint rejected id-16 Spoolman links.

Normalise the Lite 16->6 at the MQTT ingest boundary so global tray ids land
at 24-27 - matching the firmware's own bit base, working with every existing
ams_id*4+slot consumer, colliding with nothing, and passing the DB
constraint. Globalise tray_now to 24+slot, widen the valid-tray guards, label
the unit "AMS Lite", and build the confirmed ams_mapping2 {ams_id:16,
slot_id:0-3} / flat 0-3 for dispatch. Outbound slot commands translate 6->16
on the wire via a single helper. Self-scoping: only unit id 16 is touched, so
all other printers/AMS types are unaffected. One uncaptured wire field (the
physical global tray on load/cali) is extrapolated and isolated to the helper.
maziggy 1 miesiąc temu
rodzic
commit
fb11adc8fb

Plik diff jest za duży
+ 0 - 0
CHANGELOG.md


+ 6 - 2
backend/app/api/routes/printers.py

@@ -3925,8 +3925,12 @@ async def ams_load(
     - 254: external spool (single-external printers, or Ext-L on dual-nozzle H2D)
     - 255: Ext-R on dual-nozzle H2D
     """
-    if tray_id not in range(16) and tray_id not in (254, 255):
-        raise HTTPException(400, "tray_id must be 0..15 (AMS slot), 254 (external / Ext-L), or 255 (Ext-R)")
+    # 24-27 are the A2L AMS-Lite slots (normalised unit 6 = 6*4+slot); see
+    # a2l-am-unit-16. They are valid global tray ids alongside the regular 0-15.
+    if tray_id not in range(16) and tray_id not in range(24, 28) and tray_id not in (254, 255):
+        raise HTTPException(
+            400, "tray_id must be 0..15 (AMS slot), 24..27 (A2L AMS-Lite), 254 (external / Ext-L), or 255 (Ext-R)"
+        )
 
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()

+ 173 - 8
backend/app/services/bambu_mqtt.py

@@ -58,6 +58,57 @@ def parse_ams_filament_backup_from_cfg(cfg_raw: object) -> bool | None:
         return None
 
 
+# ── A2L "AMS Lite" unit-id normalisation (issue capture 2026-07-20) ──────────
+# The A2L reports its 4-slot AMS Lite as physical unit **id 16**, but the
+# firmware is internally inconsistent about it:
+#   - its tray bitmasks (tray_exist_bits etc.) sit at **bit base 24**, i.e. the
+#     position for id 6 (6*4), NOT id 16 (which would be bit 64);
+#   - it reports `tray_now` as a **local** 0-3 slot, not a global id;
+#   - `ams_mapping2` and per-unit commands use the **physical** id 16.
+# So we normalise 16 -> 6 at the MQTT ingest boundary. Global tray ids then land
+# at 24-27, which every `ams_id*4+slot` consumer handles unchanged, collides with
+# nothing (regular AMS 0-15, AMS-HT 128-135, external 254/255) and passes the
+# `ams_id <= 7` DB constraint. We translate 6 -> 16 (and the local slot) back to
+# the physical form ONLY on the outbound wire. See memory a2l-am-unit-16.
+A2L_LITE_PHYSICAL_AMS_ID = 16
+A2L_LITE_NORMALIZED_AMS_ID = 6
+A2L_LITE_GLOBAL_BASE = A2L_LITE_NORMALIZED_AMS_ID * 4  # 24
+
+
+def normalize_am_unit_id(ams_id: int) -> int:
+    """Map the A2L AMS-Lite's physical unit id (16) to its normalised id (6).
+
+    Self-scoping: only id 16 is remapped, and no other Bambu device reports an
+    AMS unit at id 16 (regular AMS 0-3, AMS-HT 128-135). All other ids pass
+    through untouched.
+    """
+    return A2L_LITE_NORMALIZED_AMS_ID if ams_id == A2L_LITE_PHYSICAL_AMS_ID else ams_id
+
+
+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.
+
+    Returns ``(wire_ams_id, wire_slot_id, wire_global_tray)`` for the AMS-Lite
+    (normalised id 6), else ``None`` for every other unit.
+
+    CONFIRMED from the firmware's own `ams_mapping2` ({ams_id:16, slot_id:0-3}):
+    the wire uses the physical unit id 16 with a **local** 0-3 slot. NOT yet
+    confirmed by capture: the physical **global** tray value some commands put on
+    the wire (load `target`, extrusion_cali `tray_id`) — we extrapolate it as
+    16*4+slot = 64-67 to stay consistent with the physical unit id. This is the
+    single unverified encoding; a BambuStudio->A2L capture of a load or cali
+    command would settle it, and it lives only here.
+    """
+    if ams_id != A2L_LITE_NORMALIZED_AMS_ID:
+        return None
+    local_slot = tray_id % 4
+    return (
+        A2L_LITE_PHYSICAL_AMS_ID,
+        local_slot,
+        A2L_LITE_PHYSICAL_AMS_ID * 4 + local_slot,
+    )
+
+
 def apply_tray_exist_bits(
     units: list,
     tray_exist_bits_str: str | int | None,
@@ -639,6 +690,11 @@ class BambuMQTTClient:
         # Intercepts slicer/Bambuddy print commands to get the slot-to-tray mapping
         self._captured_ams_mapping: list[int] | None = None
 
+        # True once we've seen (and normalised 16->6) an A2L AMS-Lite unit in the
+        # AMS telemetry. Used to globalise the Lite's local `tray_now` to 24+slot.
+        # See normalize_am_unit_id / a2l_lite_wire_ids and memory a2l-am-unit-16.
+        self._has_a2l_am_unit: bool = False
+
         # Request topic subscription tracking
         # Some printer MQTT brokers (e.g. P1S, A1) reject subscriptions to the request
         # topic by killing the TCP connection. We detect this and gracefully degrade.
@@ -1756,6 +1812,34 @@ class BambuMQTTClient:
             )
             self.on_ams_change(self.state.raw_data.get("ams") or [])
 
+    def _normalize_a2l_am_units(self, ams_list) -> None:
+        """A2L AMS-Lite normalisation (#a2l-am-unit-16): rewrite the physical unit
+        id 16 -> 6 in place, as early as possible, so every downstream reader —
+        the merge, apply_tray_exist_bits (bit base 24), the API, usage tracking,
+        the DB constraint — sees the normalised id and needs no special-casing.
+        ``tray_now`` (local) and the outbound wire are handled separately. Only id
+        16 is ever touched, so every other printer/AMS type is untouched. Runs on
+        both the dict-wrapped and bare-list AMS shapes.
+        """
+        if not isinstance(ams_list, list):
+            return
+        for unit in ams_list:
+            if not isinstance(unit, dict):
+                continue
+            try:
+                uid = int(unit.get("id"))
+            except (TypeError, ValueError):
+                continue
+            if uid == A2L_LITE_PHYSICAL_AMS_ID:
+                unit["id"] = A2L_LITE_NORMALIZED_AMS_ID
+                if not self._has_a2l_am_unit:
+                    logger.info(
+                        "[%s] A2L AMS-Lite detected (unit id 16) — normalising to id %d",
+                        self.serial_number,
+                        A2L_LITE_NORMALIZED_AMS_ID,
+                    )
+                self._has_a2l_am_unit = True
+
     def _handle_ams_data(self, ams_data):
         """Handle AMS data changes for Spoolman integration.
 
@@ -1770,6 +1854,7 @@ class BambuMQTTClient:
         if isinstance(ams_data, dict):
             if "ams" in ams_data:
                 ams_list = ams_data["ams"]
+                self._normalize_a2l_am_units(ams_list)
             # 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:
@@ -2003,9 +2088,26 @@ class BambuMQTTClient:
                             ams_exist = 0
                         num_ams = bin(ams_exist).count("1")
 
-                        if num_ams > 1:
+                        if self._has_a2l_am_unit and num_ams <= 1:
+                            # A2L AMS-Lite (normalised unit 6): the firmware reports
+                            # tray_now as a LOCAL 0-3 slot, so globalise to 24+slot —
+                            # otherwise usage tracking keys the wrong spool (it would
+                            # deduct from AMS 0's slot). Confirmed by capture:
+                            # tray_now="2" while printing physical slot 3.
+                            self.state.tray_now = A2L_LITE_GLOBAL_BASE + parsed_tray_now
+                        elif num_ams > 1:
                             # Multiple AMS on single-nozzle — tray_now is likely a local slot ID.
                             # Cross-reference with MQTT mapping field to find the correct AMS unit.
+                            if self._has_a2l_am_unit:
+                                # A2L Lite + a regular AMS attached together is out of
+                                # scope: the flat mapping ids are unknown for that combo
+                                # and could collide with AMS 0. Fall through to the
+                                # mapping-based resolve, but warn — a capture is needed.
+                                logger.warning(
+                                    "[%s] A2L AMS-Lite alongside another AMS unit is unsupported — "
+                                    "tray_now resolution may be wrong (needs a mixed-setup capture)",
+                                    self.serial_number,
+                                )
                             mapping_raw = self.state.raw_data.get("mapping")
                             resolved = self._resolve_local_slot_from_mapping(parsed_tray_now, mapping_raw)
                             if resolved is not None:
@@ -2031,9 +2133,15 @@ 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), 128-135 (AMS-HT), 254 (external spool)
+                # 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 (128 <= tn <= 135) or tn == 254:
+                if (
+                    (0 <= tn <= 15)
+                    or (A2L_LITE_GLOBAL_BASE <= tn <= A2L_LITE_GLOBAL_BASE + 3)
+                    or (128 <= tn <= 135)
+                    or tn == 254
+                ):
                     # 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
@@ -2069,6 +2177,7 @@ class BambuMQTTClient:
                 return
         elif isinstance(ams_data, list):
             ams_list = ams_data
+            self._normalize_a2l_am_units(ams_list)
         else:
             logger.warning("[%s] Unexpected AMS data format: %s", self.serial_number, type(ams_data))
             return
@@ -3652,7 +3761,12 @@ class BambuMQTTClient:
             # Clear and seed tray change log for mid-print usage splitting
             self.state.tray_change_log.clear()
             tn = self.state.tray_now
-            if (0 <= tn <= 15) or (128 <= tn <= 135) or tn == 254:
+            if (
+                (0 <= tn <= 15)
+                or (A2L_LITE_GLOBAL_BASE <= tn <= A2L_LITE_GLOBAL_BASE + 3)
+                or (128 <= tn <= 135)
+                or tn == 254
+            ):
                 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,
@@ -4124,6 +4238,14 @@ 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:
+                        # 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
+                        # (flat [1], ams_mapping2 {ams_id:16, slot_id:1}).
+                        _wire_ams, _wire_slot, _ = _a2l
+                        flat_ams_mapping.append(_wire_slot)
+                        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
@@ -4629,11 +4751,16 @@ class BambuMQTTClient:
         if not self._client:
             return False
         self._sequence_id += 1
+        # A2L AMS-Lite: normalised id 6 -> physical 16 on the wire (the Lite does
+        # not actually support drying, but keep the translation consistent). The
+        # _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
         command = {
             "print": {
                 "sequence_id": str(self._sequence_id),
                 "command": "ams_filament_drying",
-                "ams_id": ams_id,
+                "ams_id": wire_ams_id,
                 "temp": temp,
                 "cooling_temp": 20 if mode == 1 else 0,
                 "duration": duration,
@@ -5432,6 +5559,7 @@ class BambuMQTTClient:
         #     BambuStudio uses slot_id=0 (extruder index, 0=right), and
         #     curr_temp/tar_temp = the actual right-nozzle temp.  See #891.
         self._sequence_id += 1
+        wire_target = tray_id
         if tray_id == 255:
             ams_id = 255
             slot_id = 0  # extruder index for the right nozzle
@@ -5445,6 +5573,13 @@ class BambuMQTTClient:
             slot_id = 254
             curr_temp = -1
             tar_temp = -1
+        elif (_a2l := a2l_lite_wire_ids(tray_id // 4, tray_id)) is not None:
+            # A2L AMS-Lite: physical unit 16 + local slot confirmed; the wire
+            # `target` (physical global 64-67) is extrapolated (no A2L load
+            # capture yet). See a2l_lite_wire_ids.
+            ams_id, slot_id, wire_target = _a2l
+            curr_temp = -1
+            tar_temp = -1
         else:
             ams_id = tray_id // 4
             slot_id = tray_id % 4
@@ -5457,7 +5592,7 @@ class BambuMQTTClient:
                 "sequence_id": str(self._sequence_id),
                 "ams_id": ams_id,
                 "slot_id": slot_id,
-                "target": tray_id,
+                "target": wire_target,
                 "curr_temp": curr_temp,
                 "tar_temp": tar_temp,
             }
@@ -5493,6 +5628,8 @@ class BambuMQTTClient:
         # Determine source ams_id for the unload command
         if tray_now == 255 or tray_now == 254:
             ams_id = 255  # No filament or external spool
+        elif (_a2l := a2l_lite_wire_ids(tray_now // 4, tray_now)) is not None:
+            ams_id = _a2l[0]  # A2L AMS-Lite: normalised 6 -> physical 16
         else:
             ams_id = tray_now // 4  # Source AMS
 
@@ -5582,9 +5719,16 @@ class BambuMQTTClient:
             logger.warning("[%s] Cannot refresh AMS tray: filament loaded from %s", self.serial_number, loaded_tray)
             return False, f"Please unload filament first. Currently loaded: {loaded_tray}"
 
+        # A2L AMS-Lite: physical unit 16 + local slot (matches ams_mapping2).
+        wire_ams_id, wire_slot_id = ams_id, tray_id
+        if (_a2l := a2l_lite_wire_ids(ams_id, tray_id)) is not None:
+            wire_ams_id, wire_slot_id, _ = _a2l
+
         # Use ams_get_rfid command to trigger RFID re-read
         # This command is used by Bambu Studio to re-read the RFID tag
-        command = {"print": {"command": "ams_get_rfid", "ams_id": ams_id, "slot_id": tray_id, "sequence_id": "0"}}
+        command = {
+            "print": {"command": "ams_get_rfid", "ams_id": wire_ams_id, "slot_id": wire_slot_id, "sequence_id": "0"}
+        }
         self._client.publish(self.topic_publish, json.dumps(command), qos=1)
         logger.info("[%s] Triggering RFID re-read: AMS %s, slot %s", self.serial_number, ams_id, tray_id)
 
@@ -5646,6 +5790,11 @@ class BambuMQTTClient:
                 mqtt_ams_id = 255
                 mqtt_tray_id = 254
             slot_id = 0
+        elif (_a2l := a2l_lite_wire_ids(ams_id, tray_id)) is not None:
+            # A2L AMS-Lite: physical unit 16, local 0-3 slot (matches the
+            # firmware's own ams_mapping2 {ams_id:16, slot_id:0-3}).
+            mqtt_ams_id, slot_id, _ = _a2l
+            mqtt_tray_id = slot_id
         elif ams_id <= 3:
             mqtt_ams_id = ams_id
             mqtt_tray_id = tray_id
@@ -5712,6 +5861,10 @@ class BambuMQTTClient:
                 mqtt_ams_id = 255
                 mqtt_tray_id = 254
             slot_id = 0
+        elif (_a2l := a2l_lite_wire_ids(ams_id, tray_id)) is not None:
+            # 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 ams_id <= 3:
             mqtt_ams_id = ams_id
             mqtt_tray_id = tray_id
@@ -5796,6 +5949,11 @@ class BambuMQTTClient:
             mqtt_ams_id = ams_id
             mqtt_tray_id = ams_id * 4 + 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:
             mqtt_ams_id = ams_id
             mqtt_tray_id = tray_id
@@ -5860,6 +6018,13 @@ class BambuMQTTClient:
 
         nozzle_id = f"HS00-{nozzle_diameter}"
 
+        # A2L AMS-Lite: a normalised global tray (24-27) must go out as the
+        # 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:
+            wire_tray_id = _a2l[2]
+
         filament_entry = {
             "ams_id": 0,
             "cali_idx": cali_idx,
@@ -5871,7 +6036,7 @@ class BambuMQTTClient:
             "nozzle_diameter": nozzle_diameter,
             "nozzle_id": nozzle_id,
             "setting_id": setting_id,
-            "tray_id": tray_id,
+            "tray_id": wire_tray_id,
         }
 
         command = {

+ 3 - 0
backend/app/services/printer_manager.py

@@ -1067,6 +1067,9 @@ def resolve_expected_tray(
         return None
     if 4 <= raw_slot <= 15:
         return raw_slot
+    # 24-27 = A2L AMS-Lite (normalised unit 6) global tray ids, already resolved.
+    if 24 <= raw_slot <= 27:
+        return raw_slot
     return None
 
 

+ 3 - 0
backend/app/services/spool_assignment_notifications.py

@@ -27,6 +27,9 @@ def _slot_label_from_global_tray(global_tray_id: int) -> str:
         return "Ext-R"
     if global_tray_id >= 128:
         return f"HT-{chr(65 + (global_tray_id - 128))}"
+    # 24-27 = A2L AMS-Lite (normalised unit 6); see a2l-am-unit-16.
+    if 24 <= global_tray_id <= 27:
+        return f"Lite-{(global_tray_id % 4) + 1}"
     ams_id = global_tray_id // 4
     tray_id = global_tray_id % 4
     return f"{chr(65 + ams_id)}{tray_id + 1}"

+ 227 - 0
backend/tests/unit/test_a2l_ams_lite_2619.py

@@ -0,0 +1,227 @@
+"""A2L "AMS Lite" unit-id normalisation (memory a2l-am-unit-16).
+
+The A2L reports its 4-slot AMS Lite as physical unit id 16, but its tray
+bitmasks sit at bit base 24 (= id 6) and it reports tray_now as a local 0-3
+slot. We normalise 16 -> 6 at the MQTT ingest boundary so global tray ids land
+at 24-27 and every ams_id*4+slot consumer works unchanged, and translate back to
+the physical id 16 only on the outbound wire.
+
+Field values here mirror the confirmed capture (2026-07-20): physical slots 1
+empty, 2 & 3 loaded, 4 empty; tray_exist_bits "6000000"; tray_now "2" while
+printing physical slot 3.
+"""
+
+import json
+from unittest.mock import MagicMock
+
+from backend.app.services.bambu_mqtt import (
+    A2L_LITE_GLOBAL_BASE,
+    A2L_LITE_NORMALIZED_AMS_ID,
+    A2L_LITE_PHYSICAL_AMS_ID,
+    BambuMQTTClient,
+    a2l_lite_wire_ids,
+    normalize_am_unit_id,
+)
+
+
+def _client(model: str = "A2L") -> BambuMQTTClient:
+    return BambuMQTTClient(ip_address="10.0.0.1", serial_number="A2L", access_code="c", model=model)
+
+
+def _wired(client: BambuMQTTClient) -> BambuMQTTClient:
+    client._client = MagicMock()
+    client.state.connected = True
+    return client
+
+
+def _capture_frame() -> dict:
+    """One push_status frame matching Mike's 2026-07-20 capture."""
+    return {
+        "ams": [
+            {
+                "id": 16,
+                "tray": [
+                    {"id": 0},
+                    {
+                        "id": 1,
+                        "state": 3,
+                        "remain": 100,
+                        "tray_type": "",
+                        "tray_info_idx": "",
+                        "tray_color": "FFFFFF00",
+                    },
+                    {
+                        "id": 2,
+                        "state": 3,
+                        "remain": 100,
+                        "tray_type": "",
+                        "tray_info_idx": "",
+                        "tray_color": "FFFFFF00",
+                    },
+                    {"id": 3},
+                ],
+            }
+        ],
+        "ams_exist_bits": "1000",
+        "tray_exist_bits": "6000000",
+        "tray_now": "2",
+        "tray_pre": "2",
+        "tray_tar": "2",
+    }
+
+
+def _last_payload(client: BambuMQTTClient) -> dict:
+    return json.loads(client._client.publish.call_args[0][1])["print"]
+
+
+class TestHelpers:
+    def test_normalize_touches_only_16(self):
+        assert normalize_am_unit_id(A2L_LITE_PHYSICAL_AMS_ID) == A2L_LITE_NORMALIZED_AMS_ID
+        for other in (0, 1, 2, 3, 6, 15, 128, 135, 254, 255):
+            assert normalize_am_unit_id(other) == other
+
+    def test_wire_ids_only_for_normalised_6(self):
+        # (physical ams id, local slot, physical global tray)
+        assert a2l_lite_wire_ids(6, 2) == (16, 2, 66)
+        assert a2l_lite_wire_ids(6, 0) == (16, 0, 64)
+        # tray_id is taken modulo 4, so a global tray works too.
+        assert a2l_lite_wire_ids(6, 26) == (16, 2, 66)
+        # Any other unit id is left alone (returns None).
+        for ams in (0, 3, 16, 128, 255):
+            assert a2l_lite_wire_ids(ams, 2) is None
+
+
+class TestIngestNormalisation:
+    def test_unit_id_16_normalised_to_6(self):
+        client = _client()
+        client._handle_ams_data(_capture_frame())
+        assert client.state.raw_data["ams"][0]["id"] == A2L_LITE_NORMALIZED_AMS_ID
+        assert client._has_a2l_am_unit is True
+
+    def test_exists_annotation_uses_bit_base_24(self):
+        # tray_exist_bits "6000000" = bits 25,26 -> global_bit 24+slot -> slots 1,2.
+        client = _client()
+        client._handle_ams_data(_capture_frame())
+        trays = {t["id"]: t for t in client.state.raw_data["ams"][0]["tray"]}
+        assert trays[1]["exists"] is True
+        assert trays[2]["exists"] is True
+        assert trays[0]["exists"] is False
+        assert trays[3]["exists"] is False
+
+    def test_regular_ams_untouched(self):
+        client = _client(model="X1C")
+        frame = {
+            "ams": [{"id": 0, "tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]}],
+            "tray_exist_bits": "3",
+            "tray_now": "1",
+        }
+        client._handle_ams_data(frame)
+        assert client.state.raw_data["ams"][0]["id"] == 0
+        assert client._has_a2l_am_unit is False
+        assert client.state.tray_now == 1  # regular AMS 0 slot 1 == global 1
+
+    def test_bare_list_ams_shape_is_also_normalised(self):
+        # Some firmware/shapes deliver the unit list directly (no dict wrapper).
+        client = _client()
+        client._handle_ams_data([{"id": 16, "tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]}])
+        assert client.state.raw_data["ams"][0]["id"] == A2L_LITE_NORMALIZED_AMS_ID
+        assert client._has_a2l_am_unit is True
+
+
+class TestTrayNowGlobalisation:
+    def test_local_tray_now_globalised_to_24_plus_slot(self):
+        client = _client()
+        client._handle_ams_data(_capture_frame())
+        # local slot 2 -> global 26 (24 + 2)
+        assert client.state.tray_now == A2L_LITE_GLOBAL_BASE + 2 == 26
+
+    def test_globalised_tray_passes_last_valid_guard(self):
+        # last_loaded_tray is only written when the valid-tray guard accepts tn.
+        client = _client()
+        client._handle_ams_data(_capture_frame())
+        assert client.state.last_loaded_tray == 26
+
+
+class TestOutboundTranslation:
+    def test_set_filament_setting_uses_physical_16_local_slot(self):
+        client = _wired(_client())
+        assert client.ams_set_filament_setting(
+            ams_id=6,
+            tray_id=2,
+            tray_info_idx="GFL05",
+            tray_type="PLA",
+            tray_sub_brands="PLA Basic",
+            tray_color="FF0000FF",
+            nozzle_temp_min=190,
+            nozzle_temp_max=230,
+        )
+        p = _last_payload(client)
+        assert p["ams_id"] == A2L_LITE_PHYSICAL_AMS_ID
+        assert p["tray_id"] == 2
+        assert p["slot_id"] == 2
+
+    def test_reset_slot_uses_physical_16_local_slot(self):
+        client = _wired(_client())
+        assert client.reset_ams_slot(ams_id=6, tray_id=3)
+        p = _last_payload(client)
+        assert p["ams_id"] == A2L_LITE_PHYSICAL_AMS_ID
+        assert p["tray_id"] == 3
+        assert p["slot_id"] == 3
+
+    def test_cali_sel_uses_physical_global_tray(self):
+        client = _wired(_client())
+        assert client.extrusion_cali_sel(ams_id=6, tray_id=2, cali_idx=1, filament_id="GFL05")
+        p = _last_payload(client)
+        assert p["ams_id"] == A2L_LITE_PHYSICAL_AMS_ID
+        assert p["tray_id"] == 66  # 16*4 + 2 (extrapolated physical global)
+        assert p["slot_id"] == 2
+
+    def test_cali_set_remaps_global_tray(self):
+        client = _wired(_client())
+        assert client.extrusion_cali_set(tray_id=26, k_value=0.02, filament_id="GFL05")
+        p = _last_payload(client)
+        assert p["filaments"][0]["tray_id"] == 66  # 26 (normalised) -> 66 (physical)
+
+    def test_load_filament_target_and_ams(self):
+        client = _wired(_client())
+        assert client.ams_load_filament(tray_id=26)
+        p = _last_payload(client)
+        assert p["ams_id"] == A2L_LITE_PHYSICAL_AMS_ID
+        assert p["slot_id"] == 2
+        assert p["target"] == 66
+
+    def test_unload_uses_physical_ams(self):
+        client = _wired(_client())
+        client.state.tray_now = 26
+        assert client.ams_unload_filament()
+        assert _last_payload(client)["ams_id"] == A2L_LITE_PHYSICAL_AMS_ID
+
+    def test_refresh_tray_uses_physical_16(self):
+        client = _wired(_client())
+        client.state.tray_now = 255  # nothing loaded, so refresh is allowed
+        ok, _ = client.ams_refresh_tray(ams_id=6, tray_id=2)
+        assert ok
+        p = _last_payload(client)
+        assert p["ams_id"] == A2L_LITE_PHYSICAL_AMS_ID
+        assert p["slot_id"] == 2
+
+    def test_drying_uses_physical_16(self):
+        client = _wired(_client())
+        assert client.send_drying_command(ams_id=6, temp=55, duration=4, mode=1, filament="PLA")
+        assert _last_payload(client)["ams_id"] == A2L_LITE_PHYSICAL_AMS_ID
+
+    def test_regular_ams_command_unchanged(self):
+        client = _wired(_client(model="X1C"))
+        assert client.ams_set_filament_setting(
+            ams_id=0,
+            tray_id=2,
+            tray_info_idx="GFL05",
+            tray_type="PLA",
+            tray_sub_brands="PLA Basic",
+            tray_color="FF0000FF",
+            nozzle_temp_min=190,
+            nozzle_temp_max=230,
+        )
+        p = _last_payload(client)
+        assert p["ams_id"] == 0
+        assert p["tray_id"] == 2

+ 25 - 0
frontend/src/__tests__/utils/getAmsLabel.test.ts

@@ -0,0 +1,25 @@
+import { describe, it, expect } from 'vitest';
+
+import { getAmsLabel } from '../../utils/amsHelpers';
+
+describe('getAmsLabel', () => {
+  it('labels regular AMS units A/B/C by id', () => {
+    expect(getAmsLabel(0, 4)).toBe('AMS-A');
+    expect(getAmsLabel(1, 4)).toBe('AMS-B');
+  });
+
+  it('labels AMS-HT units (single tray, id >= 128)', () => {
+    expect(getAmsLabel(128, 1)).toBe('HT-A');
+    expect(getAmsLabel(129, 1)).toBe('HT-B');
+  });
+
+  it('labels the external spool', () => {
+    expect(getAmsLabel(255, 1)).toBe('External');
+  });
+
+  it('labels the A2L AMS Lite (normalised unit id 6) distinctly', () => {
+    // The backend normalises the A2L Lite's physical unit 16 -> 6; no regular
+    // AMS uses id 6, so it never collides with the A/B/C range.
+    expect(getAmsLabel(6, 4)).toBe('AMS Lite');
+  });
+});

+ 4 - 0
frontend/src/utils/amsHelpers.ts

@@ -42,6 +42,10 @@ export function getAmsLabel(amsId: number | string, trayCount: number): string {
   const id = typeof amsId === 'string' ? parseInt(amsId, 10) : amsId;
   const safeId = isNaN(id) ? 0 : id;
   if (safeId === 255) return 'External';
+  // A2L "AMS Lite": the backend normalises its physical unit id 16 to 6 at
+  // ingest (see a2l-am-unit-16). No regular AMS uses id 6, so this is a safe,
+  // self-scoping label for the Lite's 4-slot unit.
+  if (safeId === 6) return 'AMS Lite';
   const isHt = trayCount === 1;
   const normalizedId = safeId >= 128 ? safeId - 128 : safeId;
   const letter = String.fromCharCode(65 + normalizedId);

Plik diff jest za duży
+ 0 - 0
static/assets/index-DCk9Jev0.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-4hVOt7rj.js"></script>
+    <script type="module" crossorigin src="/assets/index-DCk9Jev0.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CKAbipPc.css">
   </head>
   <body>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików