Просмотр исходного кода

fix(printers): live status carries nozzles, firmware, developer mode and the print's AMS mapping; run-out hint narrows by nozzle; AMS-HT is its unit id; rotate check sees loaded slots

maziggy 4 дней назад
Родитель
Сommit
cacaaf4dae
94 измененных файлов с 652 добавлено и 111 удалено
  1. 0 16
      .basedpyright/baseline.json
  2. 5 0
      CHANGELOG.md
  3. 8 2
      backend/app/api/routes/printers.py
  4. 2 2
      backend/app/api/routes/slicer_presets.py
  5. 11 0
      backend/app/main.py
  6. 27 5
      backend/app/services/bambu_mqtt.py
  7. 1 1
      backend/app/services/print_scheduler.py
  8. 81 9
      backend/app/services/printer_manager.py
  9. 9 0
      backend/tests/integration/test_slicer_loaded_spools_3172.py
  10. 3 3
      backend/tests/unit/services/test_printer_manager.py
  11. 15 0
      backend/tests/unit/test_printer_kill_switch.py
  12. 4 0
      backend/tests/unit/test_printer_manager_status_broadcast.py
  13. 3 0
      backend/tests/unit/test_printer_offline_notification.py
  14. 308 0
      backend/tests/unit/test_printer_status_fields_g6.py
  15. 2 0
      backend/tests/unit/test_report_topic_project_file_1820.py
  16. 3 0
      backend/tests/unit/test_status_broadcast_ams_slot_config.py
  17. 0 1
      frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx
  18. 11 0
      frontend/src/__tests__/hooks/useFilamentMapping.test.ts
  19. 11 0
      frontend/src/__tests__/pages/PrintersPageDryingStartModes.test.tsx
  20. 61 0
      frontend/src/__tests__/utils/amsUnitState.test.ts
  21. 6 6
      frontend/src/__tests__/utils/getAmsLabel.test.ts
  22. 4 9
      frontend/src/components/AmsBackupModal.tsx
  23. 2 3
      frontend/src/components/ConfigureAmsSlotModal.tsx
  24. 1 1
      frontend/src/components/SliceSpoolPicker.tsx
  25. 2 1
      frontend/src/hooks/useFilamentMapping.ts
  26. 1 2
      frontend/src/hooks/useUnknownTagPrompt.ts
  27. 25 36
      frontend/src/pages/PrintersPage.tsx
  28. 0 4
      frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx
  29. 43 5
      frontend/src/utils/amsHelpers.ts
  30. 0 0
      static/assets/AdditionalSection-DV8HdAf6.js
  31. 0 0
      static/assets/AmsUnitCard-B2OG1nyK.js
  32. 0 0
      static/assets/ArchivesPage-C7fczRf8.js
  33. 0 0
      static/assets/ArchivesPage-DEc6ZjDZ.js
  34. 0 0
      static/assets/AssignToAmsModal-CzDUiysF.js
  35. 0 0
      static/assets/CamWallPage-By34W6HF.js
  36. 0 0
      static/assets/CameraDiagnoseModal-DNypnIKw.js
  37. 0 0
      static/assets/CameraPage-DvgcmaiX.js
  38. 0 0
      static/assets/CameraWall-BIkS73IB.js
  39. 0 0
      static/assets/ColumnConfigModal-DpNnog8I.js
  40. 0 0
      static/assets/ConfigureAmsSlotModal-Fa3EyG8z.js
  41. 0 0
      static/assets/ConfigureAmsSlotModal-FzduqcPo.js
  42. 0 0
      static/assets/ConnectAuthorizePage-BwW_ukN-.js
  43. 0 0
      static/assets/ContextMenu-BthLfJEG.js
  44. 0 0
      static/assets/ExternalLinkPage-CwrtTEEI.js
  45. 0 1
      static/assets/FileManagerPage-BU5NJYuK.js
  46. 1 0
      static/assets/FileManagerPage-Dm5LJwT4.js
  47. 0 0
      static/assets/FileUploadModal-CTHUlFhX.js
  48. 0 0
      static/assets/FinancePage-BCQmERfO.js
  49. 0 0
      static/assets/GCodeViewerPage-DryNa5JK.js
  50. 0 0
      static/assets/GroupEditPage-rOon9YQf.js
  51. 0 0
      static/assets/ImagePreviewModal-DP8RbGFA.js
  52. 0 0
      static/assets/InventoryPage-Cwx6YFc2.js
  53. 0 0
      static/assets/InventoryPage-DiYtR-Tf.js
  54. 0 0
      static/assets/LibraryTrashPage-C0im7JU0.js
  55. 0 0
      static/assets/LoginPage-CSfIZOQT.js
  56. 0 0
      static/assets/MaintenancePage-D1OVMgKk.js
  57. 0 0
      static/assets/MaintenancePage-DFdpB6t-.js
  58. 0 0
      static/assets/ModelSourcesPage-B0zbrQzf.js
  59. 0 0
      static/assets/ModelSourcesPage-Dhbhfvql.js
  60. 0 0
      static/assets/NotificationsPage-DV4-7rDY.js
  61. 0 1
      static/assets/PdfPreviewModal-Nuztj2SA.js
  62. 0 0
      static/assets/PhotoGalleryModal-km9KVuB4.js
  63. 0 0
      static/assets/PrinterLocationsPage-Csy18C9q.js
  64. 0 0
      static/assets/PrinterLocationsPage-DOQB2sIW.js
  65. 0 0
      static/assets/PrintersPage-DN2LKFyH.js
  66. 0 0
      static/assets/PrintersPage-fNLOYVeH.js
  67. 0 0
      static/assets/ProfilesPage-BuIp6mUb.js
  68. 0 0
      static/assets/ProfilesPage-CUpBDT4E.js
  69. 0 0
      static/assets/ProjectDetailPage-Wqw8PBoL.js
  70. 0 0
      static/assets/ProjectsPage-yK7elQdB.js
  71. 0 0
      static/assets/QueuePage-Dk_d1IWZ.js
  72. 0 0
      static/assets/QueuePage-bIUGCbAT.js
  73. 0 0
      static/assets/RichTextEditor-Bbsu8HWw.js
  74. 0 0
      static/assets/SettingsPage-CU5OrpBc.js
  75. 0 0
      static/assets/SetupPage-CtAbpIFR.js
  76. 0 0
      static/assets/SliceModal-C3QDcYku.js
  77. 0 0
      static/assets/SliceModal-CNUaxBBP.js
  78. 0 0
      static/assets/SpoolBuddyAmsPage-CbkoJPKI.js
  79. 0 0
      static/assets/SpoolBuddyAmsPage-iLuX_Pv-.js
  80. 0 0
      static/assets/SpoolBuddyDashboard-5YpvQ5hE.js
  81. 0 0
      static/assets/SpoolBuddyDashboard-LHWoEM3l.js
  82. 0 0
      static/assets/SpoolBuddyInventoryPage-Dgpo3KSr.js
  83. 0 1
      static/assets/SpoolBuddySettingsPage-D_OHQ8Bc.js
  84. 0 0
      static/assets/SpoolBuddyWriteTagPage-DiskOeiR.js
  85. 0 0
      static/assets/SpreadsheetPreviewModal-D4XdRo4q.js
  86. 0 0
      static/assets/StatsPage-X526oNLF.js
  87. 0 0
      static/assets/StreamOverlayPage-KTZsiaFq.js
  88. 0 0
      static/assets/SystemInfoPage-BHd7oyU7.js
  89. 0 0
      static/assets/index-Cab7krcD.js
  90. 1 1
      static/assets/inventorySearch-Dp-N6WfE.js
  91. 0 0
      static/assets/locationSensorDefaults-CwQ6V768.js
  92. 0 0
      static/assets/pdf-ma_kYbZS.js
  93. 0 0
      static/assets/utils-DH_CICdz.js
  94. 1 1
      static/index.html

+ 0 - 16
.basedpyright/baseline.json

@@ -2361,22 +2361,6 @@
             }
         ],
         "./backend/app/services/bambu_mqtt.py": [
-            {
-                "code": "reportAttributeAccessIssue",
-                "range": {
-                    "startColumn": 45,
-                    "endColumn": 87,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportAttributeAccessIssue",
-                "range": {
-                    "startColumn": 41,
-                    "endColumn": 83,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportArgumentType",
                 "range": {

+ 5 - 0
CHANGELOG.md

@@ -164,6 +164,11 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **The printer card shows a nozzle change, firmware update or developer-mode switch at once** — These were left out of the live updates and only reached the card with the 30-second refresh or a page reload.
+- **The print slot labels on AMS slots show up** — While a print runs, each AMS slot the print uses is meant to carry a small "P1 / P2" label, so a job sent to a printer with the wrong filament stands out. The labels read the print's mapping from the printer's own status, which almost never carries it, so they rarely appeared. Bambuddy already records the mapping from the print command, and the labels now use it. A print started from the printer's screen after a job that never started (refused, or cancelled before printing) no longer inherits that job's mapping, which usage tracking read as well.
+- **Drying no longer offers spool rotation on an AMS with filament in the feed tube** — **Rotate spool during drying** was disabled only for the tray state that means "loaded" on some firmware, so on others it stayed available and the printer refused the drying start. The slot the printer reports as loaded, and on two-nozzle printers the slot each nozzle is fed from, now count too.
+- **The run-out hint names the slot on two-nozzle printers with several AMS** — When a print paused for filament, the hint often said to check the printer screen, because the slot number alone does not say which AMS it is on. The AMS wired to the active nozzle now settles it; with the Filament Track Switch, where an AMS can feed either nozzle, the hint still asks.
+- **A regular AMS is never shown as an AMS-HT** — An AMS was taken for an AMS-HT when it reported a single tray. The unit number (128-135 for AMS-HT) now decides, on the printer card and its slot labels ("HT-B" became "AMS-B"), in the AMS Filament Backup view, print mapping, the slicer's loaded-spool list and the queue. A regular AMS that reports only some of its trays shows each one in its own slot rather than in list order.
 - **A print stopped by the printer kill switch is archived as cancelled** — The printer reports a stop as failed, and only stops made from Bambuddy were turned into cancelled, so every print the kill switch stopped went into the failure statistics as a failed print with no reason. It is now cancelled, with the reason **Stopped by the printer kill switch**, which the archive editor also offers.
 - **A queue job stopped while its printer was offline is counted as a user cancel** — Its archive stored an English sentence as the failure reason, which showed untranslated and counted as a cause of its own in the failure statistics. It now stores **User cancelled**, like every other stop; existing archives are converted on startup.
 - **Saving the SpoolBuddy backend config says it needs a restart** — The device writes a new backend URL or API token to its config file but keeps using the old ones until its daemon restarts, while the settings page only said "Config queued." It now says a restart applies it and offers **Restart Daemon**, which waits until the device has taken the config so the restart cannot replace it.

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

@@ -73,6 +73,8 @@ from backend.app.services.printer_manager import (
     display_temperatures,
     drying_screen_only,
     get_derived_status_name,
+    nozzle_wiring,
+    print_ams_mapping,
     printer_manager,
     resolve_expected_tray,
     resolve_plate_id,
@@ -729,8 +731,8 @@ async def get_printer_status(
         filament_tangle_detect=state.print_options.filament_tangle_detect,
     )
 
-    # Get AMS mapping from raw_data (which AMS is connected to which nozzle)
-    ams_mapping = raw_data.get("ams_mapping", [])
+    # The running print's AMS mapping, for the slot pills (#1762).
+    ams_mapping = print_ams_mapping(state)
     # Get per-AMS extruder map from state attribute (not raw_data, to avoid race condition
     # where raw_data gets replaced during MQTT updates and ams_extruder_map is temporarily missing)
     ams_extruder_map = state.ams_extruder_map or {}
@@ -831,6 +833,8 @@ async def get_printer_status(
                 state.tray_tar,
                 [(u.id, u.is_ams_ht) for u in ams_units],
                 raw_data.get("mapping"),
+                active_extruder=state.active_extruder,
+                ams_extruder_map=nozzle_wiring(state),
             )
             if state.state == "PAUSE"
             else None
@@ -840,6 +844,8 @@ async def get_printer_status(
                 state.tray_pre,
                 [(u.id, u.is_ams_ht) for u in ams_units],
                 raw_data.get("mapping"),
+                active_extruder=state.active_extruder,
+                ams_extruder_map=nozzle_wiring(state),
             )
             if state.state == "PAUSE"
             else None

+ 2 - 2
backend/app/api/routes/slicer_presets.py

@@ -788,8 +788,8 @@ async def list_loaded_spools(
                 _loaded_spool_tray(t, ams_id, int(t.get("id", 0)), saved.get((printer.id, ams_id, int(t.get("id", 0)))))
                 for t in raw_trays
             ]
-            # Same rule as the printer status: an AMS-HT reports a single tray.
-            units.append(LoadedSpoolUnit(id=ams_id, is_ams_ht=len(trays) == 1, trays=trays))
+            # Same rule as the printer status: an AMS-HT is a unit id 128-135.
+            units.append(LoadedSpoolUnit(id=ams_id, is_ams_ht=128 <= ams_id <= 135, trays=trays))
 
         external: list[LoadedSpoolTray] = []
         holders = [vt for vt in raw_data.get("vt_tray") or [] if isinstance(vt, dict)]

+ 11 - 0
backend/app/main.py

@@ -2055,6 +2055,17 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
         f"{ams_dry_key}:{ams_tray_key}:{state.door_open}:{state.ams_filament_backup}:{fts_key}"
     )
 
+    # Fields that change only when the machine does (a nozzle swap, a firmware
+    # update, developer mode) or once per print (its AMS mapping): no traffic,
+    # but on an idle printer nothing else moves the key when they change.
+    machine_key = (
+        tuple((n.nozzle_type, n.nozzle_diameter) for n in (state.nozzles or [])),
+        state.firmware_version,
+        state.developer_mode,
+        tuple(state.print_ams_mapping) if isinstance(state.print_ams_mapping, list) else None,
+    )
+    status_key = f"{status_key}:{machine_key}"
+
     is_active_print = state.state in _ACTIVE_PRINT_STATES
     if not is_active_print:
         _unauthorized_print_kill_sent.discard(printer_id)

+ 27 - 5
backend/app/services/bambu_mqtt.py

@@ -1039,6 +1039,9 @@ class PrinterState:
     mc_print_sub_stage: int = 0
     # AMS mapping for dual nozzle: which slot is active (from ams.ams_exist_bits/tray_exist_bits)
     ams_mapping: list = field(default_factory=list)
+    # The running print's AMS mapping (global tray per filament, -1 = none),
+    # as captured from its print command. None when no print has one.
+    print_ams_mapping: list | None = None
     # Per-AMS extruder map: {ams_id: extruder_id} where 0=right/main, 1=left/deputy
     ams_extruder_map: dict = field(default_factory=dict)
     # Filament Track Switch (FTS) accessory — when installed, AMS info reports
@@ -1527,9 +1530,10 @@ class BambuMQTTClient:
         # We use our tracked value to resolve the correct global ID
         self._last_load_tray_id: int | None = None
 
-        # Captured ams_mapping from print commands on the request topic
-        # Intercepts slicer/Bambuddy print commands to get the slot-to-tray mapping
-        self._captured_ams_mapping: list[int] | None = None
+        # The captured ams_mapping lives on the state (print_ams_mapping), so
+        # the status the card reads carries it; see _captured_ams_mapping.
+        # The destination of the dispatch it was captured from.
+        self._captured_mapping_url: str | 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.
@@ -2096,6 +2100,19 @@ class BambuMQTTClient:
         except json.JSONDecodeError:
             pass  # Ignore non-JSON MQTT messages (e.g. binary or malformed payloads)
 
+    @property
+    def _captured_ams_mapping(self):
+        """The ams_mapping captured from the current print's command.
+
+        Kept on the state so the status shows it (the slot pills, #1762): the
+        printer's own push almost never carries one.
+        """
+        return self.state.print_ams_mapping
+
+    @_captured_ams_mapping.setter
+    def _captured_ams_mapping(self, mapping) -> None:
+        self.state.print_ams_mapping = mapping
+
     def _handle_request_message(self, data: dict) -> None:
         """Intercept print commands on the request topic to capture ams_mapping."""
         print_data = data.get("print", {})
@@ -2113,6 +2130,7 @@ class BambuMQTTClient:
                 self.state.last_project_url = url
             if "ams_mapping" in print_data:
                 self._captured_ams_mapping = self._resolve_captured_mapping(print_data)
+                self._captured_mapping_url = url if isinstance(url, str) else None
                 logger.info(
                     "[%s] Captured ams_mapping from print command: %s",
                     self.serial_number,
@@ -2190,9 +2208,13 @@ class BambuMQTTClient:
         # 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):
+        # and the echo can arrive without the field at all. A mapping captured
+        # for another destination is from a dispatch that never ran (refused,
+        # or cancelled before it started, so no completion cleared it).
+        stale = self._captured_ams_mapping is not None and self._captured_mapping_url != url
+        if (self._captured_ams_mapping is None or stale) and isinstance(print_data.get("ams_mapping"), list):
             self._captured_ams_mapping = self._resolve_captured_mapping(print_data)
+            self._captured_mapping_url = url
             logger.info(
                 "[%s] Captured ams_mapping from print response: %s",
                 self.serial_number,

+ 1 - 1
backend/app/services/print_scheduler.py

@@ -4411,7 +4411,7 @@ class PrintScheduler:
         for ams_unit in ams_data:
             ams_id = int(ams_unit.get("id", 0))
             trays = ams_unit.get("tray", [])
-            is_ht = len(trays) == 1  # AMS-HT has single tray
+            is_ht = 128 <= ams_id <= 135  # AMS-HT unit ids; a regular AMS may list fewer trays
 
             for tray in trays:
                 tray_type = tray.get("tray_type")

+ 81 - 9
backend/app/services/printer_manager.py

@@ -1302,10 +1302,59 @@ def resolve_plate_id(state) -> int | None:
     return parse_plate_id(state.gcode_file)
 
 
+def print_ams_mapping(state: PrinterState) -> list:
+    """The running print's AMS mapping: the one captured from its print command,
+    else whatever the printer's push carried (it rarely does)."""
+    if isinstance(state.print_ams_mapping, list):
+        return list(state.print_ams_mapping)
+    raw = (state.raw_data or {}).get("ams_mapping")
+    return list(raw) if isinstance(raw, list) else []
+
+
+def nozzle_wiring(state: PrinterState) -> dict | None:
+    """Which nozzle each AMS is wired to, for the runout hint; None with a
+    Filament Track Switch fitted, where an AMS can feed either nozzle and the
+    map can still hold an AMS's wiring from before it moved onto the switch."""
+    if state.fila_switch and state.fila_switch.installed:
+        return None
+    return state.ams_extruder_map
+
+
+def _trays_on_extruder(
+    raw_slot: int,
+    ams_layout: list[tuple[int, bool]],
+    active_extruder: int | None,
+    ams_extruder_map: dict | None,
+) -> set[int] | None:
+    """The global trays a local ``raw_slot`` can be on the AMS units wired to
+    ``active_extruder`` (an AMS-HT's one tray for slot 0), or None when the
+    wiring is unknown.
+
+    An AMS on the Filament Track Switch has no entry in the map (it can feed
+    either nozzle), so one without an entry makes the answer unknown too.
+    """
+    if active_extruder is None or not ams_extruder_map:
+        return None
+    trays: set[int] = set()
+    for ams_id, is_ht in ams_layout:
+        nozzle = ams_extruder_map.get(str(ams_id))
+        if nozzle is None:
+            return None
+        if nozzle != active_extruder:
+            continue
+        if not is_ht:
+            trays.add(ams_id * 4 + raw_slot)
+        elif raw_slot == 0:
+            trays.add(ams_id)
+    return trays
+
+
 def resolve_expected_tray(
     raw_slot: int | None,
     ams_layout: list[tuple[int, bool]],
     mapping_raw: object,
+    active_extruder: int | None = None,
+    ams_extruder_map: dict | None = None,
 ) -> int | None:
     """Globalise a raw firmware ``tray_tar``/``tray_pre`` value for the runout UI (#2587).
 
@@ -1322,8 +1371,10 @@ def resolve_expected_tray(
     - ``0``-``3`` local slot:
         * exactly one regular AMS -> ``ams_id*4 + slot``
         * several regular AMS -> resolved via the snow-encoded ``mapping`` field
-          (each entry = ``ams_hw_id*256 + slot``; ``65535`` = unmapped), or
-          ``None`` when it stays ambiguous (honest "can't determine")
+          (each entry = ``ams_hw_id*256 + slot``; ``65535`` = unmapped), then
+          narrowed to the AMS that feed ``active_extruder`` (``ams_extruder_map``,
+          dual-nozzle printers), or ``None`` when it stays ambiguous (honest
+          "can't determine")
         * no regular AMS -> ``None``
     - ``4``-``15`` -> already a global regular-AMS ID, returned as-is
 
@@ -1341,10 +1392,8 @@ def resolve_expected_tray(
         if len(regular) == 1:
             return regular[0] * 4 + raw_slot
         if len(regular) > 1:
-            if not isinstance(mapping_raw, list):
-                return None
             candidates: set[int] = set()
-            for value in mapping_raw:
+            for value in mapping_raw if isinstance(mapping_raw, list) else []:
                 if not isinstance(value, int) or value >= 65535:
                     continue
                 ams_hw_id = value >> 8
@@ -1353,7 +1402,16 @@ def resolve_expected_tray(
                     candidates.add(ams_hw_id * 4 + raw_slot)
                 elif 128 <= ams_hw_id <= 135 and raw_slot == 0:
                     candidates.add(ams_hw_id)
-            return candidates.pop() if len(candidates) == 1 else None
+            if len(candidates) == 1:
+                return candidates.pop()
+            # The print paused on the active nozzle, so the slot is on an AMS
+            # that feeds it.
+            narrowed = _trays_on_extruder(raw_slot, ams_layout, active_extruder, ams_extruder_map)
+            if narrowed is None:
+                return None
+            if candidates:
+                narrowed &= candidates
+            return narrowed.pop() if len(narrowed) == 1 else None
         return None
     if 4 <= raw_slot <= 15:
         return raw_slot
@@ -1394,6 +1452,7 @@ def ams_units_to_dicts(
         # Skip if ams_data is not a dict (defensive check)
         if not isinstance(ams_data, dict):
             continue
+        ams_id = int(ams_data.get("id", 0))
         trays = []
         for tray in ams_data.get("tray", []):
             tag_uid = tray.get("tag_uid")
@@ -1407,7 +1466,7 @@ def ams_units_to_dicts(
             k_value = tray.get("k")
             cali_idx = tray.get("cali_idx")
             if k_value is None:
-                k_value = resolve_slot_k(cali_idx, int(ams_data.get("id", 0)), int(tray.get("id", 0)))
+                k_value = resolve_slot_k(cali_idx, ams_id, int(tray.get("id", 0)))
 
             # P1S / A1 Mini physically-empty-slot signal (#1322 follow-up by
             # @RosdasHH): for a truly empty slot the firmware sends only
@@ -1452,8 +1511,8 @@ def ams_units_to_dicts(
         humidity_pct = ams_humidity_percent(ams_data)
         humidity_value = int(round(humidity_pct)) if humidity_pct is not None else None
 
-        # AMS-HT has 1 tray, regular AMS has 4 trays
-        is_ams_ht = len(trays) == 1
+        # AMS-HT by its unit id: a regular AMS can report fewer than 4 trays.
+        is_ams_ht = 128 <= ams_id <= 135
 
         # Active-cycle filament + target temperature for the badge.
         # Bambu does not echo the cycle's chosen filament/temp on the
@@ -1656,6 +1715,8 @@ def printer_state_to_dict(
                 state.tray_tar,
                 [(u["id"], u.get("is_ams_ht", False)) for u in ams_units],
                 raw_data.get("mapping"),
+                active_extruder=state.active_extruder,
+                ams_extruder_map=nozzle_wiring(state),
             )
             if state.state == "PAUSE"
             else None
@@ -1665,12 +1726,23 @@ def printer_state_to_dict(
                 state.tray_pre,
                 [(u["id"], u.get("is_ams_ht", False)) for u in ams_units],
                 raw_data.get("mapping"),
+                active_extruder=state.active_extruder,
+                ams_extruder_map=nozzle_wiring(state),
             )
             if state.state == "PAUSE"
             else None
         ),
         # Per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
         "ams_extruder_map": ams_extruder_map,
+        # The running print's mapping, for the slot pills (#1762).
+        "ams_mapping": print_ams_mapping(state),
+        # Pushed so a change reaches the card at once rather than with the
+        # 30 s REST poll; the frontend shallow-merges each push over its status.
+        "nozzles": [
+            {"nozzle_type": n.nozzle_type, "nozzle_diameter": n.nozzle_diameter} for n in (state.nozzles or [])
+        ],
+        "firmware_version": state.firmware_version,
+        "developer_mode": state.developer_mode,
         # Filament Track Switch. Both fields have to travel on the WebSocket, not
         # only on the REST status: the frontend shallow-merges each push over its
         # cached status, so a field that is absent here keeps whatever the last

+ 9 - 0
backend/tests/integration/test_slicer_loaded_spools_3172.py

@@ -147,6 +147,15 @@ class TestLoadedSpools:
         assert ext["saved_preset"]["preset_name"] == "Generic PETG"
         assert entry["external_holders"] == 2
 
+    async def test_a_regular_ams_listing_one_tray_is_not_ht(self, async_client: AsyncClient, printer_factory):
+        """AMS-HT is the unit id 128-135, not a one-tray list."""
+        printer = await printer_factory()
+        with _patched_status({printer.id: _status({"ams": [_ams(1, [_tray(0, "PLA")])]})}):
+            response = await async_client.get(URL)
+
+        [unit] = response.json()["printers"][0]["ams"]
+        assert (unit["id"], unit["is_ams_ht"]) == (1, False)
+
     async def test_tolerates_malformed_status(self, async_client: AsyncClient, printer_factory):
         printer = await printer_factory()
         raw = {"ams": ["garbage", {"id": 1, "tray": [None, _tray(0, "ABS")]}], "vt_tray": None}

+ 3 - 3
backend/tests/unit/services/test_printer_manager.py

@@ -1308,12 +1308,12 @@ class TestPrinterStateToDict:
         assert result["cover_url"] is None
 
     def test_ams_ht_detection(self, mock_state):
-        """Verify AMS-HT is detected (1 tray vs 4)."""
+        """Verify AMS-HT is detected by its unit id (128-135)."""
         mock_state.raw_data = {
             "ams": [
                 {
-                    "id": 0,
-                    "tray": [{"id": 0}],  # Only 1 tray = AMS-HT
+                    "id": 128,
+                    "tray": [{"id": 0}],
                 }
             ]
         }

+ 15 - 0
backend/tests/unit/test_printer_kill_switch.py

@@ -95,6 +95,9 @@ async def test_unauthorized_active_print_triggers_stop(monkeypatch):
         tray_now=255,
         door_open=False,
         ams_filament_backup=False,
+        firmware_version=None,
+        developer_mode=None,
+        print_ams_mapping=None,
         current_print=None,
         subtask_name="foreign_job",
         subtask_id="external-task-1",
@@ -190,6 +193,9 @@ async def test_bambuddy_authorized_print_is_not_stopped(monkeypatch):
         tray_now=255,
         door_open=False,
         ams_filament_backup=False,
+        firmware_version=None,
+        developer_mode=None,
+        print_ams_mapping=None,
         current_print=None,
         subtask_name="foreign_job",
         gcode_file="foreign_job.gcode",
@@ -275,6 +281,9 @@ async def test_unauthorized_print_state_is_cleared_when_print_ends(monkeypatch):
         tray_now=255,
         door_open=False,
         ams_filament_backup=False,
+        firmware_version=None,
+        developer_mode=None,
+        print_ams_mapping=None,
         current_print=None,
         subtask_name="foreign_job",
         subtask_id="external-task-1",
@@ -305,6 +314,9 @@ async def test_unauthorized_print_state_is_cleared_when_print_ends(monkeypatch):
         tray_now=255,
         door_open=False,
         ams_filament_backup=False,
+        firmware_version=None,
+        developer_mode=None,
+        print_ams_mapping=None,
         current_print=None,
         subtask_name="",
         subtask_id=None,
@@ -389,6 +401,9 @@ async def test_persisted_print_is_authorized_after_restart(monkeypatch, printer_
         tray_now=255,
         door_open=False,
         ams_filament_backup=False,
+        firmware_version=None,
+        developer_mode=None,
+        print_ams_mapping=None,
         current_print=None,
         subtask_name="owned_job",
         subtask_id="bambuddy-task-123",

+ 4 - 0
backend/tests/unit/test_printer_manager_status_broadcast.py

@@ -102,6 +102,10 @@ def _fake_state(**overrides):
         "wifi_signal": None,
         "wired_network": None,
         "ams_filament_backup": None,
+        "developer_mode": None,
+        "nozzles": [],
+        "ams_extruder_map": {},
+        "print_ams_mapping": None,
         # Filament Track Switch. None means "no accessory", which is what
         # printer_state_to_dict gates both of these on.
         "fila_switch": None,

+ 3 - 0
backend/tests/unit/test_printer_offline_notification.py

@@ -71,6 +71,9 @@ def _state(connected: bool, state: str = "IDLE") -> SimpleNamespace:
         door_open=False,
         subtask_name="",
         ams_filament_backup=None,
+        firmware_version=None,
+        developer_mode=None,
+        print_ams_mapping=None,
     )
 
 

+ 308 - 0
backend/tests/unit/test_printer_status_fields_g6.py

@@ -0,0 +1,308 @@
+"""Printer status fields the card reads, on both the REST status and the WebSocket.
+
+- Nozzles, firmware version and developer mode were REST-only, so the card
+  showed them up to 30 s late (the fallback poll) after a change.
+- The print's AMS mapping (the "P1 / P2" slot pills, #1762) was read from the
+  printer's push, which almost never carries it. Bambuddy captures it from the
+  print command; that capture is what the status reports now.
+- The runout hint on a printer with several AMS said "check the printer" unless
+  the snow mapping named the slot. The AMS that feeds the active nozzle names it.
+- AMS-HT was told apart by reporting one tray; it is the unit id 128-135.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app import main as main_module
+from backend.app.services.bambu_mqtt import BambuMQTTClient, NozzleInfo, PrinterState
+from backend.app.services.printer_manager import ams_units_to_dicts, printer_state_to_dict, resolve_expected_tray
+
+
+def _state(**fields) -> PrinterState:
+    state = PrinterState()
+    state.connected = True
+    state.state = "IDLE"
+    for name, value in fields.items():
+        setattr(state, name, value)
+    return state
+
+
+class TestTheWebSocketCarriesThem:
+    def test_nozzles_firmware_and_developer_mode(self):
+        state = _state(
+            nozzles=[NozzleInfo(nozzle_type="hardened_steel", nozzle_diameter="0.4")],
+            firmware_version="01.09.00.00",
+            developer_mode=True,
+        )
+
+        pushed = printer_state_to_dict(state)
+
+        assert pushed["nozzles"] == [{"nozzle_type": "hardened_steel", "nozzle_diameter": "0.4"}]
+        assert pushed["firmware_version"] == "01.09.00.00"
+        assert pushed["developer_mode"] is True
+
+
+def _push_state(**overrides):
+    fields = {
+        "connected": True,
+        "state": "IDLE",
+        "progress": 0,
+        "layer_num": 0,
+        "total_layers": 0,
+        "temperatures": {},
+        "nozzles": [],
+        "raw_data": {},
+        "stg_cur": 0,
+        "fila_switch": None,
+        "ams_switch_inlet": {},
+        "extruder_slots": {},
+        "cooling_fan_speed": 0,
+        "big_fan1_speed": 0,
+        "big_fan2_speed": 0,
+        "chamber_light": "",
+        "active_extruder": 0,
+        "tray_now": 255,
+        "door_open": False,
+        "subtask_name": "",
+        "gcode_file": "",
+        "remaining_time": None,
+        "hms_errors": [],
+        "ams_filament_backup": None,
+        "firmware_version": "01.09.00.00",
+        "developer_mode": False,
+        "print_ams_mapping": None,
+    }
+    fields.update(overrides)
+    return SimpleNamespace(**fields)
+
+
+@pytest.fixture
+def ws_mgr():
+    main_module._last_status_broadcast.clear()
+    main_module._printer_last_connected.clear()
+    main_module._printer_reconciled_since_connect.clear()
+    mgr = MagicMock()
+    mgr.send_printer_status = AsyncMock()
+    yield mgr
+    main_module._last_status_broadcast.clear()
+    main_module._printer_last_connected.clear()
+    main_module._printer_reconciled_since_connect.clear()
+
+
+async def _push(ws_mgr, state) -> None:
+    relay = MagicMock()
+    relay.on_printer_status = AsyncMock()
+    pm = MagicMock()
+    pm.get_printer.return_value = None
+    pm.get_model.return_value = ""
+    with (
+        patch("backend.app.main.ws_manager", ws_mgr),
+        patch("backend.app.main.mqtt_relay", relay),
+        patch("backend.app.main.printer_manager", pm),
+        patch("backend.app.main.spawn_background_task", side_effect=lambda coro, **kwargs: coro.close()),
+        patch("backend.app.main.printer_state_to_dict", return_value={}),
+    ):
+        await main_module.on_printer_status_change(1, state)
+
+
+class TestAChangeAloneIsPushed:
+    """An idle printer's other fields stand still, so the change has to move
+    the broadcast's dedup key by itself."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "change",
+        [
+            {"developer_mode": True},
+            {"firmware_version": "01.10.00.00"},
+            {"nozzles": [NozzleInfo(nozzle_type="stainless_steel", nozzle_diameter="0.6")]},
+            {"print_ams_mapping": [0, 2]},
+        ],
+    )
+    async def test_it_breaks_the_dedup(self, ws_mgr, change):
+        await _push(ws_mgr, _push_state())
+        await _push(ws_mgr, _push_state(**change))
+
+        assert ws_mgr.send_printer_status.await_count == 2
+
+
+def _client() -> BambuMQTTClient:
+    return BambuMQTTClient(ip_address="10.0.0.9", serial_number="SN-G6", access_code="x", model="X1C")
+
+
+class TestThePrintsMapping:
+    def test_a_captured_mapping_is_in_the_status(self):
+        client = _client()
+        client._handle_request_message(
+            {"print": {"command": "project_file", "url": "ftp://x.3mf", "ams_mapping": [2, 0, -1]}}
+        )
+
+        assert client.state.print_ams_mapping == [2, 0, -1]
+        client.state.state = "RUNNING"
+        assert printer_state_to_dict(client.state)["ams_mapping"] == [2, 0, -1]
+
+    def test_it_is_gone_with_the_print(self):
+        client = _client()
+        client._captured_ams_mapping = [1]
+        client._captured_ams_mapping = None
+
+        assert client.state.print_ams_mapping is None
+        assert printer_state_to_dict(client.state)["ams_mapping"] == []
+
+
+class TestAMappingLeftFromAPrintThatNeverStarted:
+    """A dispatch the printer refused or that was cancelled before it ran ends
+    no print, so its captured mapping stayed and the next print from the
+    printer's screen showed it."""
+
+    def test_the_next_prints_own_mapping_replaces_it(self):
+        client = _client()
+        client._handle_request_message(
+            {"print": {"command": "project_file", "url": "ftp://old.3mf", "ams_mapping": [3]}}
+        )
+
+        client._capture_report_project_file(
+            {
+                "command": "project_file",
+                "result": "SUCCESS",
+                "url": "file:///userdata/model/history/new.gcode.3mf",
+                "ams_mapping": [1],
+            }
+        )
+
+        assert client.state.print_ams_mapping == [1]
+
+    def test_the_echo_of_the_same_dispatch_keeps_the_slicers_copy(self):
+        client = _client()
+        client._handle_request_message(
+            {"print": {"command": "project_file", "url": "ftp://job.3mf", "ams_mapping": [3]}}
+        )
+
+        client._capture_report_project_file(
+            {"command": "project_file", "result": "SUCCESS", "url": "ftp://job.3mf", "ams_mapping": [0]}
+        )
+
+        assert client.state.print_ams_mapping == [3]
+
+
+class TestTheRunoutHintOnSeveralAms:
+    """Two regular AMS, one per nozzle (H2D); no snow mapping in the push."""
+
+    LAYOUT = [(0, False), (1, False)]
+    NOZZLES = {"0": 0, "1": 1}
+
+    def test_the_active_nozzles_ams_names_the_slot(self):
+        assert resolve_expected_tray(2, self.LAYOUT, None, active_extruder=1, ams_extruder_map=self.NOZZLES) == 6
+
+    def test_it_narrows_an_ambiguous_mapping(self):
+        mapping = [0 * 256 + 2, 1 * 256 + 2]
+
+        assert resolve_expected_tray(2, self.LAYOUT, mapping, active_extruder=0, ams_extruder_map=self.NOZZLES) == 2
+
+    def test_the_mapping_picks_among_the_nozzles_ams(self):
+        """Two AMS on the active nozzle: the mapping's entry for one of them
+        decides, its entry on the other nozzle's AMS does not count."""
+        layout = [(0, False), (1, False), (2, False)]
+        nozzles = {"0": 0, "1": 0, "2": 1}
+        mapping = [0 * 256 + 2, 2 * 256 + 2]
+
+        assert resolve_expected_tray(2, layout, mapping, active_extruder=0, ams_extruder_map=nozzles) == 2
+
+    def test_an_ams_ht_on_that_nozzle_counts_too(self):
+        """Slot 0 can be the AMS-HT's one tray as well."""
+        layout = [(0, False), (1, False), (128, True)]
+        nozzles = {"0": 1, "1": 0, "128": 0}
+        mapping = [1 * 256 + 0, 128 * 256 + 0]
+
+        assert resolve_expected_tray(0, layout, mapping, active_extruder=0, ams_extruder_map=nozzles) is None
+        assert resolve_expected_tray(0, layout, None, active_extruder=0, ams_extruder_map=nozzles) is None
+        assert resolve_expected_tray(1, layout, None, active_extruder=0, ams_extruder_map=nozzles) == 5
+
+    def test_two_ams_on_that_nozzle_still_ask(self):
+        layout = [(0, False), (1, False), (2, False)]
+        nozzles = {"0": 0, "1": 0, "2": 1}
+
+        assert resolve_expected_tray(2, layout, None, active_extruder=0, ams_extruder_map=nozzles) is None
+
+    def test_an_ams_on_the_track_switch_is_not_guessed(self):
+        """An AMS on the Filament Track Switch has no nozzle entry: it can feed either."""
+        assert resolve_expected_tray(2, self.LAYOUT, None, active_extruder=1, ams_extruder_map={"1": 1}) is None
+
+    def test_without_a_nozzle_nothing_changes(self):
+        assert resolve_expected_tray(2, self.LAYOUT, None) is None
+
+    def test_a_fitted_track_switch_is_not_guessed_either(self):
+        """The map keeps an AMS's old wiring after it moves onto the switch."""
+        from backend.app.services.bambu_mqtt import FilaSwitchState
+
+        state = _state(
+            state="PAUSE",
+            tray_tar=2,
+            active_extruder=1,
+            ams_extruder_map=dict(self.NOZZLES),
+            fila_switch=FilaSwitchState(installed=True),
+            raw_data={"ams": [{"id": "0", "tray": [{"id": str(i)} for i in range(4)]}, {"id": "1", "tray": []}]},
+        )
+
+        assert printer_state_to_dict(state)["expected_tray"] is None
+
+    def test_rest_and_websocket_pass_the_nozzle(self):
+        state = _state(
+            state="PAUSE",
+            tray_tar=2,
+            active_extruder=1,
+            ams_extruder_map=dict(self.NOZZLES),
+            raw_data={"ams": [{"id": "0", "tray": [{"id": str(i)} for i in range(4)]}, {"id": "1", "tray": []}]},
+        )
+
+        assert printer_state_to_dict(state)["expected_tray"] == 6
+
+
+class TestAmsHtIsItsUnitId:
+    def test_a_regular_ams_reporting_one_tray_is_not_ht(self):
+        units = ams_units_to_dicts([{"id": "0", "tray": [{"id": "0", "tray_type": "PLA"}]}], lambda *a: None, None)
+
+        assert units[0]["is_ams_ht"] is False
+
+    def test_an_ht_unit_is_ht(self):
+        units = ams_units_to_dicts([{"id": "128", "tray": [{"id": "0", "tray_type": "PLA"}]}], lambda *a: None, None)
+
+        assert units[0]["is_ams_ht"] is True
+
+    def test_the_scheduler_reads_the_unit_id(self):
+        from backend.app.services.print_scheduler import PrintScheduler
+
+        status = SimpleNamespace(
+            raw_data={"ams": [{"id": "1", "tray": [{"id": "0", "tray_type": "PLA", "tray_color": "FF0000FF"}]}]},
+            nozzles=[],
+        )
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = status
+            pm.get_model.return_value = "X1C"
+            filaments = PrintScheduler()._build_loaded_filaments(status)
+
+        assert [(f["global_tray_id"], f["is_ht"]) for f in filaments] == [(4, False)]
+
+
+@pytest.mark.asyncio
+async def test_the_rest_status_reports_them_too(async_client, printer_factory):
+    printer = await printer_factory()
+    state = _state(
+        state="PAUSE",
+        tray_tar=2,
+        active_extruder=1,
+        ams_extruder_map={"0": 0, "1": 1},
+        print_ams_mapping=[6, -1],
+        raw_data={"ams": [{"id": "0", "tray": [{"id": str(i)} for i in range(4)]}, {"id": "1", "tray": []}]},
+    )
+    with patch("backend.app.api.routes.printers.printer_manager") as pm:
+        pm.get_status = MagicMock(return_value=state)
+        pm.is_awaiting_plate_clear = MagicMock(return_value=False)
+        pm.get_drying_targets = MagicMock(return_value={})
+        response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
+
+    body = response.json()
+    assert (body["expected_tray"], body["ams_mapping"]) == (6, [6, -1])
+    assert [unit["is_ams_ht"] for unit in body["ams"]] == [False, False]

+ 2 - 0
backend/tests/unit/test_report_topic_project_file_1820.py

@@ -94,6 +94,8 @@ class TestAScreenStartedPrintNamesItsFile:
         """The slicer's own mapping describes the same print and arrived first;
         the echo can carry a different shape or none at all."""
         client._captured_ams_mapping = [0, -1, -1, -1]
+        # Captured from the request for this same destination.
+        client._captured_mapping_url = "file:///userdata/model/history/JOB_A.gcode.3mf"
 
         client._process_message(screen_start(ams_mapping=[3]))
 

+ 3 - 0
backend/tests/unit/test_status_broadcast_ams_slot_config.py

@@ -89,6 +89,9 @@ def _state(trays: list[dict], ams_extra: dict | None = None) -> SimpleNamespace:
         remaining_time=None,
         hms_errors=[],
         ams_filament_backup=None,
+        firmware_version=None,
+        developer_mode=None,
+        print_ams_mapping=None,
     )
 
 

+ 0 - 1
frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx

@@ -70,7 +70,6 @@ const defaultProps = {
   slotInfo: {
     amsId: 0,
     trayId: 0,
-    trayCount: 4,
     trayType: 'PLA',
     trayColor: 'FFFFFF',
     traySubBrands: 'PLA Basic',

+ 11 - 0
frontend/src/__tests__/hooks/useFilamentMapping.test.ts

@@ -62,6 +62,17 @@ describe('buildLoadedFilaments', () => {
     });
   });
 
+  it('tells AMS-HT by its unit id, not by a one-tray list', () => {
+    const status = createPrinterStatus([
+      { id: 1, tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' }] },
+      { id: 128, tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFG00' }] },
+    ] as PrinterStatus['ams']);
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result.map(f => [f.globalTrayId, f.isHt])).toEqual([[4, false], [128, true]]);
+  });
+
   it('includes tray_info_idx from AMS trays', () => {
     const status = createPrinterStatus([
       {

+ 11 - 0
frontend/src/__tests__/pages/PrintersPageDryingStartModes.test.tsx

@@ -129,6 +129,17 @@ describe('PrintersPage - drying start modes', () => {
     );
   });
 
+  it('does not offer rotation while the printer reports a slot of this AMS as loaded', async () => {
+    // The tray state code for "loaded" differs by firmware; tray_now does not.
+    server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json({ ...IDLE, tray_now: 2 })));
+    const user = userEvent.setup();
+    render(<PrintersPage />);
+    await openDryingPopover(user);
+
+    const rotate = screen.getByRole('button', { name: 'Rotate spool during drying' });
+    expect((rotate as HTMLButtonElement).disabled).toBe(true);
+  });
+
   it('lets the temperature be retyped instead of snapping to the 45 °C minimum (#3182)', async () => {
     const user = userEvent.setup();
     render(<PrintersPage />);

+ 61 - 0
frontend/src/__tests__/utils/amsUnitState.test.ts

@@ -0,0 +1,61 @@
+/**
+ * AMS-HT is told apart by its unit id (128-135), not by reporting one tray;
+ * and an AMS whose filament is threaded into the feed tube cannot rotate its
+ * spools for drying. The tray state code for "loaded" (11) differs between
+ * firmware versions, so the slot the printer reports as loaded counts too.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { amsHasLoadedTray, isAmsHtUnit, trayForSlot } from '../../utils/amsHelpers';
+
+const tray = (id: number, state?: number) => ({ id, state });
+
+describe('isAmsHtUnit', () => {
+  it('is the unit id range', () => {
+    expect(isAmsHtUnit(128)).toBe(true);
+    expect(isAmsHtUnit(135)).toBe(true);
+    expect(isAmsHtUnit(0)).toBe(false);
+    expect(isAmsHtUnit(136)).toBe(false);
+  });
+});
+
+describe('amsHasLoadedTray', () => {
+  it('sees the loaded state code', () => {
+    expect(amsHasLoadedTray({ id: 0, tray: [tray(0, 10), tray(1, 11)] }, 255, {})).toBe(true);
+  });
+
+  it('sees the slot the printer reports as loaded, whatever its state code', () => {
+    expect(amsHasLoadedTray({ id: 1, tray: [tray(0, 10), tray(1, 10)] }, 5, {})).toBe(true);
+    expect(amsHasLoadedTray({ id: 1, tray: [tray(0, 10), tray(1, 10)] }, 1, {})).toBe(false);
+  });
+
+  it('sees an AMS-HT that is loaded', () => {
+    expect(amsHasLoadedTray({ id: 128, tray: [tray(0, 10)] }, 128, {})).toBe(true);
+  });
+
+  it('sees a hotend fed from it on a two-nozzle printer', () => {
+    // tray_now names only the active nozzle's slot; the other hotend's comes
+    // from extruder_slots.
+    const slots = { '1': { ams_id: 2, slot_id: 3, has_filament: true } };
+    expect(amsHasLoadedTray({ id: 2, tray: [tray(3, 10)] }, 255, slots)).toBe(true);
+    expect(amsHasLoadedTray({ id: 2, tray: [tray(3, 10)] }, 255, { '1': { ...slots['1'], has_filament: false } })).toBe(false);
+  });
+
+  it('is false for an idle AMS', () => {
+    expect(amsHasLoadedTray({ id: 0, tray: [tray(0, 10), tray(1, 9)] }, 255, {})).toBe(false);
+    expect(amsHasLoadedTray(undefined, 0, {})).toBe(false);
+  });
+});
+
+describe('trayForSlot', () => {
+  it('finds a slot by its tray id when the AMS reports only some trays', () => {
+    const trays = [{ id: 2, tray_type: 'PETG' }];
+    expect(trayForSlot(trays, 2)?.tray_type).toBe('PETG');
+    expect(trayForSlot(trays, 0)).toBeUndefined();
+  });
+
+  it('falls back to the position when the ids do not tell the trays apart', () => {
+    const trays = [{ id: 0, tray_type: 'PLA' }, { id: 0, tray_type: 'PETG' }];
+    expect(trayForSlot(trays, 1)?.tray_type).toBe('PETG');
+  });
+});

+ 6 - 6
frontend/src/__tests__/utils/getAmsLabel.test.ts

@@ -4,22 +4,22 @@ 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');
+    expect(getAmsLabel(0)).toBe('AMS-A');
+    expect(getAmsLabel(1)).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');
+    expect(getAmsLabel(128)).toBe('HT-A');
+    expect(getAmsLabel(129)).toBe('HT-B');
   });
 
   it('labels the external spool', () => {
-    expect(getAmsLabel(255, 1)).toBe('External');
+    expect(getAmsLabel(255)).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');
+    expect(getAmsLabel(6)).toBe('AMS Lite');
   });
 });

+ 4 - 9
frontend/src/components/AmsBackupModal.tsx

@@ -20,6 +20,7 @@ import { useTranslation } from 'react-i18next';
 import { Toggle } from './Toggle';
 import {
   computeBackupGroups,
+  isAmsHtUnit,
   normalizeColor,
   type AmsUnitLike,
   type BackupGroup,
@@ -41,8 +42,8 @@ interface AmsBackupModalProps {
  * Compact slot label like "A·3" / "HT·1" — the ring is small, every char
  * counts toward readability.
  */
-function formatSlotLabel(amsId: number, slotIdx: number, totalTraysOnUnit: number): string {
-  const isHt = totalTraysOnUnit === 1 || amsId >= 128;
+function formatSlotLabel(amsId: number, slotIdx: number): string {
+  const isHt = isAmsHtUnit(amsId);
   const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
   const letter = String.fromCharCode(65 + normalizedId);
   return isHt ? `HT·${slotIdx + 1}` : `${letter}·${slotIdx + 1}`;
@@ -62,7 +63,6 @@ function pickContrastTextColor(rgbaHex: string | null | undefined): string {
 
 function BackupRing({
   group,
-  trayCountByAms,
   innerBg,
   textPrimary,
   textSecondary,
@@ -70,7 +70,6 @@ function BackupRing({
   extruderLabel,
 }: {
   group: BackupGroup;
-  trayCountByAms: Map<number, number>;
   innerBg: string;
   textPrimary: string;
   textSecondary: string;
@@ -142,7 +141,7 @@ function BackupRing({
           const rad = (angleDeg * Math.PI) / 180;
           const x = labelRadius * Math.cos(rad);
           const y = labelRadius * Math.sin(rad);
-          const label = formatSlotLabel(m.amsId, m.slotIdx, trayCountByAms.get(m.amsId) ?? 4);
+          const label = formatSlotLabel(m.amsId, m.slotIdx);
           // Approximate pill width based on char count (each digit ≈ 6.5 px @ 12 px font).
           const pillWidth = Math.max(22, label.length * 7 + 8);
           return (
@@ -230,9 +229,6 @@ export function AmsBackupModal({
   })();
 
   const groups = computeBackupGroups(amsUnits, amsExtruderMap, effectiveDualNozzle);
-  const trayCountByAms = new Map<number, number>(
-    (amsUnits || []).map((u) => [u.id, u.tray.length]),
-  );
 
   // Only pairs are rendered — lone slots are deliberately suppressed.
   const pairs = groups.filter((g) => g.members.length >= 2);
@@ -313,7 +309,6 @@ export function AmsBackupModal({
                 <BackupRing
                   key={g.key}
                   group={g}
-                  trayCountByAms={trayCountByAms}
                   innerBg={modalBg}
                   textPrimary={textPrimary}
                   textSecondary={textSecondary}

+ 2 - 3
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -16,7 +16,6 @@ import { useToast } from '../contexts/ToastContext';
 interface SlotInfo {
   amsId: number;
   trayId: number;
-  trayCount: number;
   trayType?: string;
   trayColor?: string;
   traySubBrands?: string;
@@ -1287,7 +1286,7 @@ export function ConfigureAmsSlotModal({
                   />
                 )}
                 <span className="text-white/70">
-                  {t('configureAmsSlot.slotLabel', { ams: getAmsLabel(slotInfo.amsId, slotInfo.trayCount), slot: slotInfo.trayId + 1 })}
+                  {t('configureAmsSlot.slotLabel', { ams: getAmsLabel(slotInfo.amsId), slot: slotInfo.trayId + 1 })}
                 </span>
                 {slotInfo.traySubBrands && (
                   <span>({slotInfo.traySubBrands})</span>
@@ -1328,7 +1327,7 @@ export function ConfigureAmsSlotModal({
                   />
                 )}
                 <span className="text-white font-medium">
-                  {t('configureAmsSlot.slotLabel', { ams: getAmsLabel(slotInfo.amsId, slotInfo.trayCount), slot: slotInfo.trayId + 1 })}
+                  {t('configureAmsSlot.slotLabel', { ams: getAmsLabel(slotInfo.amsId), slot: slotInfo.trayId + 1 })}
                 </span>
                 {slotInfo.traySubBrands && (
                   <span className="text-bambu-gray">({slotInfo.traySubBrands})</span>

+ 1 - 1
frontend/src/components/SliceSpoolPicker.tsx

@@ -135,7 +135,7 @@ function PrinterSection({
       <div className="space-y-3">
         {regular.map((unit) => (
           <div key={unit.id}>
-            <div className="text-xs text-bambu-gray mb-1">{getAmsLabel(unit.id, unit.trays.length)}</div>
+            <div className="text-xs text-bambu-gray mb-1">{getAmsLabel(unit.id)}</div>
             <div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
               {unit.trays.map((tray) => tile(tray, formatSlotLabel(unit.id, tray.tray_id, false, false)))}
             </div>

+ 2 - 1
frontend/src/hooks/useFilamentMapping.ts

@@ -9,6 +9,7 @@ import {
   findNearestSimilar,
   formatSlotLabel,
   getGlobalTrayId,
+  isAmsHtUnit,
   preferLowestSortKey,
   compareSortKeys,
 } from '../utils/amsHelpers';
@@ -66,7 +67,7 @@ export function buildLoadedFilaments(
 
   // Add filaments from all AMS units (regular and HT)
   printerStatus?.ams?.forEach((amsUnit) => {
-    const isHt = amsUnit.tray.length === 1; // AMS-HT has single tray
+    const isHt = isAmsHtUnit(amsUnit.id);
     amsUnit.tray.forEach((tray) => {
       if (tray.tray_type) {
         const color = normalizeColor(tray.tray_color);

+ 1 - 2
frontend/src/hooks/useUnknownTagPrompt.ts

@@ -57,13 +57,12 @@ export function useUnknownTagPrompt() {
       if (!detail.tray_type) return null;
       const printers = queryClient.getQueryData<Printer[]>(['printers']);
       const printer = printers?.find(p => p.id === detail.printer_id);
-      const trayCount = detail.tray_count ?? 4;
       return {
         printer_id: detail.printer_id,
         ams_id: detail.ams_id,
         tray_id: detail.tray_id,
         printer_name: printer?.name ?? `Printer ${detail.printer_id}`,
-        ams_label: getAmsLabel(detail.ams_id, trayCount),
+        ams_label: getAmsLabel(detail.ams_id),
         slot_label: `${t('inventory.unknownSpoolSlot', 'Slot')} ${detail.tray_id + 1}`,
         material: detail.tray_type ?? null,
         color_hex: detail.tray_color ?? null,

+ 25 - 36
frontend/src/pages/PrintersPage.tsx

@@ -197,7 +197,7 @@ import { FileUploadModal } from '../components/FileUploadModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { FeedDirectionModal } from '../components/FeedDirectionModal';
-import { getAmsLabel, getEmptySlotKind, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, installedNozzleDiameters, isBambuLabSpool, resolveSlotNozzleDiameter, resolveSlotExtruder, formatSlotLabel, slotPresetDescribesTray, FTS_INLET_SIDE } from '../utils/amsHelpers';
+import { amsHasLoadedTray, getAmsLabel, getEmptySlotKind, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, installedNozzleDiameters, isAmsHtUnit, isBambuLabSpool, trayForSlot, resolveSlotNozzleDiameter, resolveSlotExtruder, formatSlotLabel, slotPresetDescribesTray, FTS_INLET_SIDE } from '../utils/amsHelpers';
 import { MAX_CHAMBER_TEMP_C, getPrinterImage, getWifiStrength, filterCompatibleQueueItems, isPrinterCurrentlyDispatchable } from '../utils/printer';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { Collapsible } from '../components/Collapsible';
@@ -2255,7 +2255,6 @@ function PrinterCard({
   const [configureSlotModal, setConfigureSlotModal] = useState<{
     amsId: number;
     trayId: number;
-    trayCount: number;
     trayType?: string;
     trayColor?: string;
     traySubBrands?: string;
@@ -2536,11 +2535,11 @@ function PrinterCard({
     if (globalId === null) return null;
     if (globalId === 254) return t('printers.expectedSlot.external');
     if (globalId >= 128 && globalId <= 135) {
-      return amsLabels?.[globalId] || getAmsLabel(globalId, 1);
+      return amsLabels?.[globalId] || getAmsLabel(globalId);
     }
     const amsId = Math.floor(globalId / 4);
     const slot = globalId % 4;
-    const amsName = amsLabels?.[amsId] || getAmsLabel(amsId, 4);
+    const amsName = amsLabels?.[amsId] || getAmsLabel(amsId);
     return t('printers.expectedSlot.label', { ams: amsName, slot: slot + 1 });
   };
   const runoutGuidance = status?.state === 'PAUSE'
@@ -5279,9 +5278,9 @@ function PrinterCard({
 
             {/* AMS Units - 2-Column Grid Layout */}
             {(amsData?.length > 0 || status.vt_tray.length > 0) && viewMode === 'expanded' && (() => {
-              // Separate regular AMS (4-tray) from HT AMS (1-tray)
-              const regularAms = amsData.filter(ams => ams.tray.length > 1);
-              const htAms = amsData.filter(ams => ams.tray.length === 1);
+              // Separate regular AMS (4-tray) from HT AMS (1-tray), by unit id
+              const regularAms = amsData.filter(ams => !isAmsHtUnit(ams.id));
+              const htAms = amsData.filter(ams => isAmsHtUnit(ams.id));
               // The external spool can only be hidden while some AMS remains to
               // fill the row (#1782). On an A1 Mini or a bare P1P it is the whole
               // filament section, so the toggle is not offered there and a stored
@@ -5375,13 +5374,13 @@ function PrinterCard({
                                 <AmsNameHoverCard
                                   ams={ams}
                                   printerId={printer.id}
-                                  label={getAmsLabel(ams.id, ams.tray.length)}
+                                  label={getAmsLabel(ams.id)}
                                   amsLabels={amsLabels}
                                   canEdit={hasPermission('printers:update')}
                                   onSaved={refetchAmsLabels}
                                 >
                                   <span className="block truncate text-[length:var(--pc-t10,10px)] text-white font-medium cursor-default select-none">
-                                    {amsLabels?.[ams.id] || getAmsLabel(ams.id, ams.tray.length)}
+                                    {amsLabels?.[ams.id] || getAmsLabel(ams.id)}
                                   </span>
                                 </AmsNameHoverCard>
                                 {sideBadge?.kind === 'inlet' ? (
@@ -5405,7 +5404,7 @@ function PrinterCard({
                                       fairThreshold={amsThresholds?.humidityFair}
                                       onClick={() => setAmsHistoryModal({
                                         amsId: ams.id,
-                                        amsLabel: getAmsLabel(ams.id, ams.tray.length),
+                                        amsLabel: getAmsLabel(ams.id),
                                         mode: 'humidity',
                                       })}
                                       compact
@@ -5419,7 +5418,7 @@ function PrinterCard({
                                         fairThreshold={amsThresholds?.tempFair}
                                         onClick={() => setAmsHistoryModal({
                                           amsId: ams.id,
-                                          amsLabel: getAmsLabel(ams.id, ams.tray.length),
+                                          amsLabel: getAmsLabel(ams.id),
                                           mode: 'temperature',
                                         })}
                                         compact
@@ -5525,8 +5524,7 @@ function PrinterCard({
                             <div className="grid w-full gap-1" style={slotGridStyle(4)}>
                               {[0, 1, 2, 3].map((slotIdx) => {
                                 // Find tray data for this slot (may be undefined if data incomplete)
-                                // Use array index if available, as tray.id may not always be set
-                                const tray = ams.tray[slotIdx] || ams.tray.find(t => t.id === slotIdx);
+                                const tray = trayForSlot(ams.tray, slotIdx);
                                 const hasFillLevel = tray?.tray_type && tray.remain >= 0;
                                 const isEmpty = !tray?.tray_type;
                                 const emptyKind = getEmptySlotKind(tray);
@@ -5746,7 +5744,7 @@ function PrinterCard({
                                                   material: tray?.tray_type ?? undefined,
                                                   profile: filamentData.profile,
                                                   color: filamentData.colorHex || '',
-                                                  location: `${getAmsLabel(ams.id, ams.tray.length)} Slot ${slotIdx + 1}`,
+                                                  location: `${getAmsLabel(ams.id)} Slot ${slotIdx + 1}`,
                                                 },
                                               }),
                                               onUnassignSpool: (spoolmanSpool && !isBambuLabSpool(tray)) ? () => onUnassignSpoolmanSpool?.(spoolmanSpool.id) : undefined,
@@ -5776,7 +5774,7 @@ function PrinterCard({
                                                 material: tray?.tray_type ?? undefined,
                                                 profile: filamentData.profile,
                                                 color: filamentData.colorHex || '',
-                                                location: `${getAmsLabel(ams.id, ams.tray.length)} Slot ${slotIdx + 1}`,
+                                                location: `${getAmsLabel(ams.id)} Slot ${slotIdx + 1}`,
                                               },
                                             }),
                                             onUnassignSpool: (assignment && !isBambuLabSpool(tray)) ? () => onUnassignSpool?.(printer.id, ams.id, slotIdx) : undefined,
@@ -5788,7 +5786,6 @@ function PrinterCard({
                                           onConfigure: () => setConfigureSlotModal({
                                             amsId: ams.id,
                                             trayId: slotIdx,
-                                            trayCount: ams.tray.length,
                                             trayType: tray?.tray_type || undefined,
                                             trayColor: tray?.tray_color || undefined,
                                             traySubBrands: tray?.tray_sub_brands || undefined,
@@ -5815,7 +5812,6 @@ function PrinterCard({
                                           onConfigure: () => setConfigureSlotModal({
                                             amsId: ams.id,
                                             trayId: slotIdx,
-                                            trayCount: ams.tray.length,
                                             extruderId: resolveSlotExtruder(ams.id, tray?.id ?? 0, amsExtruderMap, amsSwitchInlet),
                                           }),
                                         }}
@@ -5828,7 +5824,7 @@ function PrinterCard({
                                             material: undefined,
                                             profile: '',
                                             color: '',
-                                            location: `${getAmsLabel(ams.id, ams.tray.length)} Slot ${slotIdx + 1}`,
+                                            location: `${getAmsLabel(ams.id)} Slot ${slotIdx + 1}`,
                                           },
                                         })}
                                       >
@@ -6004,13 +6000,13 @@ function PrinterCard({
                                 <AmsNameHoverCard
                                   ams={ams}
                                   printerId={printer.id}
-                                  label={getAmsLabel(ams.id, ams.tray.length)}
+                                  label={getAmsLabel(ams.id)}
                                   amsLabels={amsLabels}
                                   canEdit={hasPermission('printers:update')}
                                   onSaved={refetchAmsLabels}
                                 >
                                   <span className="block truncate text-[length:var(--pc-t10,10px)] text-white font-medium cursor-default select-none">
-                                    {amsLabels?.[ams.id] || getAmsLabel(ams.id, ams.tray.length)}
+                                    {amsLabels?.[ams.id] || getAmsLabel(ams.id)}
                                   </span>
                                 </AmsNameHoverCard>
                                 {sideBadge?.kind === 'inlet' ? (
@@ -6163,7 +6159,7 @@ function PrinterCard({
                                               material: tray?.tray_type ?? undefined,
                                               profile: filamentData.profile,
                                               color: filamentData.colorHex || '',
-                                              location: getAmsLabel(ams.id, ams.tray.length),
+                                              location: getAmsLabel(ams.id),
                                             },
                                           }),
                                           onUnassignSpool: (spoolmanSpool && !isBambuLabSpool(tray)) ? () => onUnassignSpoolmanSpool?.(spoolmanSpool.id) : undefined,
@@ -6193,7 +6189,7 @@ function PrinterCard({
                                             material: tray?.tray_type ?? undefined,
                                             profile: filamentData.profile,
                                             color: filamentData.colorHex || '',
-                                            location: getAmsLabel(ams.id, ams.tray.length),
+                                            location: getAmsLabel(ams.id),
                                           },
                                         }),
                                         onUnassignSpool: (assignment && !isBambuLabSpool(tray)) ? () => onUnassignSpool?.(printer.id, ams.id, htSlotId) : undefined,
@@ -6205,7 +6201,6 @@ function PrinterCard({
                                       onConfigure: () => setConfigureSlotModal({
                                         amsId: ams.id,
                                         trayId: htSlotId,
-                                        trayCount: ams.tray.length,
                                         trayType: tray?.tray_type || undefined,
                                         trayColor: tray?.tray_color || undefined,
                                         traySubBrands: tray?.tray_sub_brands || undefined,
@@ -6233,7 +6228,6 @@ function PrinterCard({
                                       onConfigure: () => setConfigureSlotModal({
                                         amsId: ams.id,
                                         trayId: htSlotId,
-                                        trayCount: ams.tray.length,
                                         extruderId: resolveSlotExtruder(ams.id, tray?.id ?? 0, amsExtruderMap, amsSwitchInlet),
                                       }),
                                     }}
@@ -6246,7 +6240,7 @@ function PrinterCard({
                                         material: undefined,
                                         profile: '',
                                         color: '',
-                                        location: getAmsLabel(ams.id, ams.tray.length),
+                                        location: getAmsLabel(ams.id),
                                       },
                                     })}
                                   >
@@ -6264,7 +6258,7 @@ function PrinterCard({
                                       fairThreshold={amsThresholds?.tempFair}
                                       onClick={() => setAmsHistoryModal({
                                         amsId: ams.id,
-                                        amsLabel: getAmsLabel(ams.id, ams.tray.length),
+                                        amsLabel: getAmsLabel(ams.id),
                                         mode: 'temperature',
                                       })}
                                       compact
@@ -6277,7 +6271,7 @@ function PrinterCard({
                                       fairThreshold={amsThresholds?.humidityFair}
                                       onClick={() => setAmsHistoryModal({
                                         amsId: ams.id,
-                                        amsLabel: getAmsLabel(ams.id, ams.tray.length),
+                                        amsLabel: getAmsLabel(ams.id),
                                         mode: 'humidity',
                                       })}
                                       compact
@@ -6498,7 +6492,6 @@ function PrinterCard({
                                         onConfigure: () => setConfigureSlotModal({
                                           amsId: 255,
                                           trayId: slotTrayId,
-                                          trayCount: 1,
                                           trayType: extTray.tray_type || undefined,
                                           trayColor: extTray.tray_color || undefined,
                                           traySubBrands: extTray.tray_sub_brands || undefined,
@@ -6525,7 +6518,6 @@ function PrinterCard({
                                         onConfigure: () => setConfigureSlotModal({
                                           amsId: 255,
                                           trayId: slotTrayId,
-                                          trayCount: 1,
                                           extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined,
                                         }),
                                       }}
@@ -7525,14 +7517,13 @@ function PrinterCard({
                     Catches both mid-print (active feed) AND idle-with-threaded-
                     filament — the H2D's post-print state leaves filament in the
                     tube but tray_now resets to 255, which a tray_now-only check
-                    would silently miss. */}
+                    would silently miss. The state code differs by firmware, so
+                    the loaded slot (tray_now, extruder_slots) counts too. */}
                 {(() => {
                   const targetAms = dryingPopoverAmsId !== null
                     ? amsData.find(a => a.id === dryingPopoverAmsId)
                     : undefined;
-                  const trayLoadedInThisAms = (targetAms?.tray ?? []).some(
-                    tray => tray.state === 11,
-                  );
+                  const trayLoadedInThisAms = amsHasLoadedTray(targetAms, status?.tray_now, status?.extruder_slots);
                   const rotateChecked = dryingRotateTray && !trayLoadedInThisAms;
                   return (
                     <button
@@ -7616,9 +7607,7 @@ function PrinterCard({
                       // Start payload would carry rotate_tray=true and firmware would
                       // reject with dry_sf_reason=[3] (ConsumableAtAmsOutlet).
                       const targetAms = amsData.find(a => a.id === dryingPopoverAmsId);
-                      const trayLoadedInThisAms = (targetAms?.tray ?? []).some(
-                        tray => tray.state === 11,
-                      );
+                      const trayLoadedInThisAms = amsHasLoadedTray(targetAms, status?.tray_now, status?.extruder_slots);
                       const rotate = dryingRotateTray && !trayLoadedInThisAms;
                       if (dryingStartMode === 'now') {
                         startDryingMutation.mutate({

+ 0 - 4
frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx

@@ -244,7 +244,6 @@ export function SpoolBuddyAmsPage() {
   const [configureSlotModal, setConfigureSlotModal] = useState<{
     amsId: number;
     trayId: number;
-    trayCount: number;
     trayType?: string;
     trayColor?: string;
     traySubBrands?: string;
@@ -258,7 +257,6 @@ export function SpoolBuddyAmsPage() {
   const [slotActionPicker, setSlotActionPicker] = useState<{
     amsId: number;
     trayId: number;
-    trayCount: number;
     tray: AMSTray | null;
     trayType?: string;
     trayColor?: string;
@@ -382,7 +380,6 @@ export function SpoolBuddyAmsPage() {
     const slotData = {
       amsId,
       trayId,
-      trayCount: tray ? (amsId >= 128 ? 1 : 4) : 4,
       tray,
       trayType: tray?.tray_type || undefined,
       trayColor: tray?.tray_color || undefined,
@@ -407,7 +404,6 @@ export function SpoolBuddyAmsPage() {
     const slotData = {
       amsId: 255,
       trayId: slotTrayId,
-      trayCount: 1,
       tray: isTrayEmpty(extTray) ? null : extTray,
       trayType: extTray.tray_type || undefined,
       trayColor: extTray.tray_color || undefined,

+ 43 - 5
frontend/src/utils/amsHelpers.ts

@@ -76,11 +76,9 @@ export function resolveSlotExtruder(
 /**
  * AMS unit label using the codebase convention: "AMS-A / AMS-B / ..." for
  * regular AMS, "HT-A / HT-B / ..." for AMS-HT (single-tray modules with
- * IDs starting at 128). `trayCount` is required because the type can't be
- * inferred from the id alone — regular AMS IDs 0-3 can collide with the
- * normalized HT range otherwise.
+ * IDs 128-135). The unit id decides: a regular AMS can report one tray.
  */
-export function getAmsLabel(amsId: number | string, trayCount: number): string {
+export function getAmsLabel(amsId: number | string): string {
   const id = typeof amsId === 'string' ? parseInt(amsId, 10) : amsId;
   const safeId = isNaN(id) ? 0 : id;
   if (safeId === 255) return 'External';
@@ -88,7 +86,7 @@ export function getAmsLabel(amsId: number | string, trayCount: number): string {
   // 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 isHt = isAmsHtUnit(safeId);
   const normalizedId = safeId >= 128 ? safeId - 128 : safeId;
   const letter = String.fromCharCode(65 + normalizedId);
   return isHt ? `HT-${letter}` : `AMS-${letter}`;
@@ -392,6 +390,46 @@ export function getGlobalTrayId(
   return amsId * 4 + trayId;
 }
 
+/**
+ * AMS-HT units are ids 128-135, one tray each. The id decides, not the tray
+ * count: a regular AMS can report fewer than four trays.
+ */
+export function isAmsHtUnit(amsId: number): boolean {
+  return amsId >= 128 && amsId <= 135;
+}
+
+/**
+ * The tray reported for a slot of a regular AMS, by its tray id. An AMS can
+ * report only some of its trays, and then a position in the list is not the
+ * slot. Only when the ids do not tell the trays apart (a firmware that sends
+ * none: they all read 0) does the position decide.
+ */
+export function trayForSlot<T extends { id: number }>(trays: T[], slotIdx: number): T | undefined {
+  const idsUsable = new Set(trays.map(tray => tray.id)).size === trays.length;
+  return idsUsable ? trays.find(tray => tray.id === slotIdx) : trays[slotIdx];
+}
+
+/**
+ * Whether some spool of this AMS is threaded into the feed tube. The AMS turns
+ * its spools together, so one loaded slot keeps it from rotating them while
+ * drying (the firmware refuses with dry_sf_reason 3, consumable at AMS outlet).
+ *
+ * Tray state 11 means loaded, but not on every firmware, so the slot the
+ * printer reports as loaded (tray_now) counts too, and on a two-nozzle printer
+ * the slot each hotend is fed from (extruder_slots), since tray_now names only
+ * the active nozzle's.
+ */
+export function amsHasLoadedTray(
+  ams: { id: number; tray: { id: number; state?: number | null }[] } | undefined,
+  trayNow: number | null | undefined,
+  extruderSlots: Record<string, { ams_id: number | null; has_filament: boolean }> | null | undefined,
+): boolean {
+  if (!ams) return false;
+  if (ams.tray.some(tray => tray.state === 11)) return true;
+  if (trayNow != null && ams.tray.some(tray => getGlobalTrayId(ams.id, tray.id, false) === trayNow)) return true;
+  return Object.values(extruderSlots ?? {}).some(slot => slot.ams_id === ams.id && slot.has_filament);
+}
+
 /**
  * Get fill bar color based on spool fill level.
  * Matches PrintersPage thresholds and Bambu Lab brand green.

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/AdditionalSection-DV8HdAf6.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/AmsUnitCard-B2OG1nyK.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ArchivesPage-C7fczRf8.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ArchivesPage-DEc6ZjDZ.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/AssignToAmsModal-CzDUiysF.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CamWallPage-By34W6HF.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CameraDiagnoseModal-DNypnIKw.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CameraPage-DvgcmaiX.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CameraWall-BIkS73IB.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ColumnConfigModal-DpNnog8I.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ConfigureAmsSlotModal-Fa3EyG8z.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ConfigureAmsSlotModal-FzduqcPo.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ConnectAuthorizePage-BwW_ukN-.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ContextMenu-BthLfJEG.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ExternalLinkPage-CwrtTEEI.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/FileManagerPage-BU5NJYuK.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
static/assets/FileManagerPage-Dm5LJwT4.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/FileUploadModal-CTHUlFhX.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/FinancePage-BCQmERfO.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/GCodeViewerPage-DryNa5JK.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/GroupEditPage-rOon9YQf.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ImagePreviewModal-DP8RbGFA.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/InventoryPage-Cwx6YFc2.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/InventoryPage-DiYtR-Tf.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/LibraryTrashPage-C0im7JU0.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/LoginPage-CSfIZOQT.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/MaintenancePage-D1OVMgKk.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/MaintenancePage-DFdpB6t-.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ModelSourcesPage-B0zbrQzf.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ModelSourcesPage-Dhbhfvql.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/NotificationsPage-DV4-7rDY.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/PdfPreviewModal-Nuztj2SA.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PhotoGalleryModal-km9KVuB4.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PrinterLocationsPage-Csy18C9q.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PrinterLocationsPage-DOQB2sIW.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PrintersPage-DN2LKFyH.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PrintersPage-fNLOYVeH.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ProfilesPage-BuIp6mUb.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ProfilesPage-CUpBDT4E.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ProjectDetailPage-Wqw8PBoL.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ProjectsPage-yK7elQdB.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/QueuePage-Dk_d1IWZ.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/QueuePage-bIUGCbAT.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/RichTextEditor-Bbsu8HWw.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SettingsPage-CU5OrpBc.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SetupPage-CtAbpIFR.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SliceModal-C3QDcYku.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SliceModal-CNUaxBBP.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyAmsPage-CbkoJPKI.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyAmsPage-iLuX_Pv-.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyDashboard-5YpvQ5hE.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyDashboard-LHWoEM3l.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-Dgpo3KSr.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/SpoolBuddySettingsPage-D_OHQ8Bc.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-DiskOeiR.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpreadsheetPreviewModal-D4XdRo4q.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/StatsPage-X526oNLF.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/StreamOverlayPage-KTZsiaFq.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SystemInfoPage-BHd7oyU7.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-Cab7krcD.js


+ 1 - 1
static/assets/inventorySearch-CsPruB3X.js → static/assets/inventorySearch-Dp-N6WfE.js

@@ -1 +1 @@
-import{mt as e}from"./index-x8im7lBz.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};
+import{_t as e}from"./index-Cab7krcD.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/locationSensorDefaults-CwQ6V768.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/pdf-ma_kYbZS.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/utils-DH_CICdz.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-x8im7lBz.js"></script>
+    <script type="module" crossorigin src="/assets/index-Cab7krcD.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-p9vNUtaN.js">
     <link rel="modulepreload" crossorigin href="/assets/QueryClientProvider-B5zKGR0V.js">

Некоторые файлы не были показаны из-за большого количества измененных файлов