فهرست منبع

fix(ams): Load and Unload work on an AMS-HT

The printer card sent unit*4+slot for an AMS-HT slot (512 for HT-A), which
the load route refused, and the commands turned the AMS-HT into ams_id 32.
An AMS-HT tray is now addressed by its unit id (128-135), as tray_now
reports it and as BambuStudio sends it (ams_id = target = unit, slot 0);
the A2L AMS-Lite load target is its unit 16, as BambuStudio sends. Unload
from an AMS-HT slot names that slot, a load behind a Filament Track Switch
asks which nozzle for an AMS-HT too, and the dual-nozzle load confirmation
resolves AMS-HT B-H to their unit instead of AMS 0.
maziggy 4 روز پیش
والد
کامیت
5a9415e2e1
63فایلهای تغییر یافته به همراه251 افزوده شده و 47 حذف شده
  1. 1 0
      CHANGELOG.md
  2. 14 4
      backend/app/api/routes/printers.py
  3. 35 21
      backend/app/services/bambu_mqtt.py
  4. 32 0
      backend/tests/integration/test_printers_api.py
  5. 11 0
      backend/tests/unit/services/test_bambu_mqtt.py
  6. 3 1
      backend/tests/unit/test_a2l_ams_lite_2619.py
  7. 55 0
      backend/tests/unit/test_ams_ht_load_unload.py
  8. 88 0
      frontend/src/__tests__/pages/PrintersPageAmsLoadUnload.test.tsx
  9. 2 1
      frontend/src/api/client.ts
  10. 7 15
      frontend/src/pages/PrintersPage.tsx
  11. 0 0
      static/assets/AdditionalSection-BOgrberw.js
  12. 0 0
      static/assets/AmsUnitCard-CQpqKENa.js
  13. 0 0
      static/assets/ArchivesPage-5Jmgxy5i.js
  14. 0 0
      static/assets/AssignToAmsModal-DEfyNw7q.js
  15. 0 0
      static/assets/CamWallPage-BE2Z4tti.js
  16. 0 0
      static/assets/CameraDiagnoseModal-CdYuChg8.js
  17. 0 0
      static/assets/CameraPage-DVUKNaO5.js
  18. 0 0
      static/assets/CameraWall-Bsfqfb9Q.js
  19. 0 0
      static/assets/ColumnConfigModal-D1k223Hh.js
  20. 0 0
      static/assets/ConfigureAmsSlotModal-CJh5MPWm.js
  21. 0 0
      static/assets/ConnectAuthorizePage-Dbyhz4gk.js
  22. 0 0
      static/assets/ContextMenu-ujcH62Z3.js
  23. 0 0
      static/assets/ExternalLinkPage-B61TtAO8.js
  24. 0 1
      static/assets/FileManagerPage-C1-0cUp8.js
  25. 0 0
      static/assets/FileUploadModal-ByBnFAi0.js
  26. 0 0
      static/assets/FinancePage-DNp3Qbe0.js
  27. 0 0
      static/assets/GCodeViewerPage-D53rH9BU.js
  28. 0 0
      static/assets/GroupEditPage-BIVxIPg-.js
  29. 0 0
      static/assets/ImagePreviewModal--0-2GG08.js
  30. 0 0
      static/assets/InventoryPage-B9y3EjxU.js
  31. 0 0
      static/assets/LibraryTrashPage-Br3_o-jV.js
  32. 0 0
      static/assets/LoginPage-Dhds2WsE.js
  33. 0 0
      static/assets/MaintenancePage-Bqc2qiUi.js
  34. 0 0
      static/assets/ModelSourcesPage-DUvT0hO1.js
  35. 0 0
      static/assets/NotificationsPage-D4ucPiUo.js
  36. 0 1
      static/assets/PdfPreviewModal-CJcTx6-v.js
  37. 0 0
      static/assets/PhotoGalleryModal-CwNMwtev.js
  38. 0 0
      static/assets/PrinterLocationsPage-oWYMgF_L.js
  39. 0 0
      static/assets/PrintersPage-BEdXwOSn.js
  40. 0 0
      static/assets/ProfilesPage-CuKL4VW9.js
  41. 0 0
      static/assets/ProjectDetailPage-DwZCHiXU.js
  42. 0 0
      static/assets/ProjectsPage-BkHB6VSK.js
  43. 0 0
      static/assets/QueuePage-qy7-9qls.js
  44. 0 0
      static/assets/RichTextEditor-DPwmDlQ2.js
  45. 0 0
      static/assets/SettingsPage-CLe1k-pV.js
  46. 0 0
      static/assets/SetupPage-_XXeZzw4.js
  47. 0 0
      static/assets/SliceModal-J4x0-Nhc.js
  48. 0 0
      static/assets/SpoolBuddyAmsPage-CfNjFFS2.js
  49. 0 0
      static/assets/SpoolBuddyCalibrationPage-DQIvapoi.js
  50. 0 0
      static/assets/SpoolBuddyDashboard-26qsRUxy.js
  51. 0 0
      static/assets/SpoolBuddyInventoryPage-CQMBdQKC.js
  52. 1 1
      static/assets/SpoolBuddySettingsPage-DNyVTsjq.js
  53. 0 0
      static/assets/SpoolBuddyWriteTagPage-dKTLFvwH.js
  54. 0 0
      static/assets/SpreadsheetPreviewModal-BPJ33BJE.js
  55. 0 0
      static/assets/StatsPage-CrzPQqB3.js
  56. 0 0
      static/assets/StreamOverlayPage-DA073hUg.js
  57. 0 0
      static/assets/SystemInfoPage-C0qD7Rjc.js
  58. 0 0
      static/assets/index-C6-VhZuX.js
  59. 1 1
      static/assets/inventorySearch-BijmL26U.js
  60. 0 0
      static/assets/locationSensorDefaults-Dt_perQo.js
  61. 0 0
      static/assets/pdf-CyA8sM9C.js
  62. 0 0
      static/assets/utils-Cro50Tx_.js
  63. 1 1
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -164,6 +164,7 @@ 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
+- **Load and Unload work on an AMS-HT** — Clicking **Load** on an AMS-HT slot was refused with "tray_id must be 0..15 …": the printer card numbered the slot like a regular AMS (unit × 4 + slot, 512 for the first AMS-HT), and the printer's commands would have turned the AMS-HT into a unit it does not have. **Unload** while the AMS-HT was the loaded tray was addressed to that same non-existent unit. Both now address the AMS-HT by its own unit number, as Bambu Studio does, and **Unload** from an AMS-HT slot's menu is aimed at that slot. Loading from the A2L's AMS-Lite now sends the target Bambu Studio sends as well.
 - **A second Bambuddy on the same machine no longer cuts the camera every minute** — Bambuddy's once-a-minute cleanup kills camera ffmpeg processes left behind by a crash or restart, and recognised them by their command alone. Another Bambuddy on the same host (a second install, a test instance) runs the very same command, so each one's cleanup killed the other's live camera once a minute and the view reconnected. Only an ffmpeg started by this Bambuddy, or left orphaned by an earlier run, is cleaned up now.
 - **A restart with a camera view open no longer logs errors** — A camera stream never ends by itself, so a restart closes it after 5 seconds. Each stream closed that way was logged as an ERROR with a full traceback ("Exception in ASGI application ... timeout graceful shutdown exceeded"), which looked like a crash in the log and in support bundles. It is now one INFO line per stream, and the "Cancel N running task(s)" line before them is INFO too; real errors are logged as before.
 - **Archive export includes the whole end day, and an invalid print speed is refused** — `GET /api/v1/archives/export?date_to=2026-10-15` read the date as midnight and left out everything printed that day; a date on its own now ends with that day, as in every other date filter, and a date and time with a UTC offset is converted to UTC. The Archives page was not affected, because it never sends dates. `POST /api/v1/printers/{id}/print-speed` with a mode other than 1-4 answered 500 "Failed to set print speed"; it now answers 422.

+ 14 - 4
backend/app/api/routes/printers.py

@@ -4641,19 +4641,26 @@ async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
 
 
 # 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.
-_LOAD_TRAY_ID_ERROR = "tray_id must be 0..15 (AMS slot), 24..27 (A2L AMS-Lite), 254 (external / Ext-L), or 255 (Ext-R)"
+# a2l-am-unit-16. An AMS-HT holds one tray, numbered by its unit id 128-135, as
+# the printer's tray_now reports it. All are valid alongside the regular 0-15.
+_LOAD_TRAY_ID_ERROR = (
+    "tray_id must be 0..15 (AMS slot), 24..27 (A2L AMS-Lite), 128..135 (AMS-HT), 254 (external / Ext-L), or 255 (Ext-R)"
+)
 
 
 def _is_valid_load_tray_id(tray_id: int) -> bool:
     """Whether ``tray_id`` names a slot the load/unload commands can address."""
-    return tray_id in range(16) or tray_id in range(24, 28) or tray_id in (254, 255)
+    return tray_id in range(16) or tray_id in range(24, 28) or tray_id in range(128, 136) or tray_id in (254, 255)
 
 
 @router.post("/{printer_id}/ams/load")
 async def ams_load(
     printer_id: int,
-    tray_id: int = Query(..., description="Tray ID: 0-15 for AMS slots (ams_id*4+slot_id), 254 for external spool"),
+    tray_id: int = Query(
+        ...,
+        description="Tray ID: 0-15 for AMS slots (ams_id*4+slot_id), 128-135 for an AMS-HT (its unit id), "
+        "254 for external spool",
+    ),
     extruder_id: int | None = Query(
         None,
         ge=0,
@@ -4673,6 +4680,7 @@ async def ams_load(
 
     Tray ID encoding (matches Bambu firmware convention):
     - 0..15: AMS slot, computed as ams_id * 4 + slot_id
+    - 128..135: AMS-HT, the unit id (one tray per unit)
     - 254: external spool (single-external printers, or Ext-L on dual-nozzle H2D)
     - 255: Ext-R on dual-nozzle H2D
     """
@@ -4696,6 +4704,8 @@ async def ams_load(
         target = "external spool"
     elif tray_id == 255:
         target = "Ext-R"
+    elif tray_id >= 128:
+        target = f"AMS HT-{chr(ord('A') + tray_id - 128)}"
     else:
         target = f"AMS {tray_id // 4} slot {tray_id % 4 + 1}"
     return {"success": True, "message": f"Loading filament from {target}"}

+ 35 - 21
backend/app/services/bambu_mqtt.py

@@ -179,11 +179,12 @@ def a2l_lite_wire_ids(ams_id: int, tray_id: int) -> tuple[int, int, int] | None:
 
     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.
+    confirmed by capture: the physical **global** tray value the extrusion_cali
+    commands put in `tray_id` — we extrapolate it as 16*4+slot = 64-67 to stay
+    consistent with the physical unit id. BambuStudio's source sends 24+slot
+    there instead (``GetTrayIndexMap``); a BambuStudio->A2L capture of a cali
+    command would settle it, and it lives only here. Load's `target` is not this
+    value: see ``change_filament_wire_ids``.
     """
     if ams_id != A2L_LITE_NORMALIZED_AMS_ID:
         return None
@@ -195,6 +196,22 @@ def a2l_lite_wire_ids(ams_id: int, tray_id: int) -> tuple[int, int, int] | None:
     )
 
 
+def change_filament_wire_ids(tray_id: int) -> tuple[int, int, int]:
+    """``(ams_id, slot_id, target)`` of ``ams_change_filament`` for an AMS tray.
+
+    As BambuStudio's ``command_ams_change_filament`` builds them: a regular AMS
+    is addressed by unit and local slot with the global tray as target; a unit
+    id of 16 or more (the A2L AMS-Lite's physical 16, an AMS-HT's 128-135) has
+    the unit id itself as target. An AMS-HT holds one tray, so its global tray
+    id is its unit id and its slot is 0.
+    """
+    if 128 <= tray_id <= 135:
+        return tray_id, 0, tray_id
+    if (a2l := a2l_lite_wire_ids(tray_id // 4, tray_id)) is not None:
+        return a2l[0], a2l[1], a2l[0]
+    return tray_id // 4, tray_id % 4, tray_id
+
+
 def resolve_external_spools_in_mapping(ams_mapping: object, ams_mapping2: object, is_dual_nozzle: bool) -> object:
     """Put the external spool back into a captured flat ``ams_mapping`` (#3166).
 
@@ -3221,7 +3238,9 @@ class BambuMQTTClient:
                     # First, check if we have a pending target that matches this slot
                     pending_target = self.state.pending_tray_target
                     if pending_target is not None:
-                        pending_slot = pending_target % 4
+                        # Same slot rule as the snow branch below: an AMS-HT
+                        # (128-135) has its one tray in slot 0.
+                        pending_slot = 0 if 128 <= pending_target <= 135 else pending_target % 4
                         if pending_slot == parsed_tray_now:
                             # Slot matches our pending target - use the full global ID
                             logger.debug(
@@ -7591,7 +7610,8 @@ class BambuMQTTClient:
         """Load filament from a specific AMS tray.
 
         Args:
-            tray_id: Global tray ID — 0..15 for AMS slots, 254 for external spool
+            tray_id: Global tray ID — 0..15 for AMS slots, 24..27 for the A2L
+                AMS-Lite, 128..135 for an AMS-HT (its unit id), 254 for external spool
                 (single-external printers and Ext-L on dual-nozzle H2D),
                 255 for Ext-R on dual-nozzle H2D.
             extruder_id: Which hotend to feed (0 = right/main, 1 = left/deputy).
@@ -7613,7 +7633,8 @@ class BambuMQTTClient:
             return False
 
         # Build the ams_change_filament command. Encoding differs by target type:
-        #   - AMS slots (0..15): slot_id is the local slot, curr/tar_temp = -1.
+        #   - AMS slots (0..15, A2L 24..27, AMS-HT 128..135): see
+        #     change_filament_wire_ids; curr/tar_temp = -1.
         #   - External spool (tray_id=254): legacy capture from a single-extruder
         #     printer used slot_id=254, curr/tar_temp=-1; preserved here.
         #   - Ext-R on dual-nozzle H2D (tray_id=255): captured shape from
@@ -7634,16 +7655,10 @@ 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
+            # A regular AMS, the A2L AMS-Lite (physical unit 16) or an AMS-HT
+            # (128-135), as BambuStudio addresses each.
+            ams_id, slot_id, wire_target = change_filament_wire_ids(tray_id)
             curr_temp = -1
             tar_temp = -1
 
@@ -7705,10 +7720,9 @@ class BambuMQTTClient:
         # Determine source ams_id for the unload command
         if source_tray == 255 or source_tray == 254:
             ams_id = 255  # No filament or external spool
-        elif (_a2l := a2l_lite_wire_ids(source_tray // 4, source_tray)) is not None:
-            ams_id = _a2l[0]  # A2L AMS-Lite: normalised 6 -> physical 16
         else:
-            ams_id = source_tray // 4  # Source AMS
+            # Source AMS: the A2L AMS-Lite as physical 16, an AMS-HT as its unit id.
+            ams_id = change_filament_wire_ids(source_tray)[0]
 
         # Refuse an addressed unload of a slot no hotend is holding — but only on
         # a printer that has more than one hotend, which is the only case the
@@ -7731,7 +7745,7 @@ class BambuMQTTClient:
         # The external spool is excluded for a different reason: 254/255 are not
         # ams*4+slot, so the local-slot arithmetic below cannot describe them.
         if tray_id is not None and tray_id not in (254, 255) and self._is_dual_nozzle and self.state.extruder_slots:
-            local_slot = _a2l[1] if (_a2l := a2l_lite_wire_ids(tray_id // 4, tray_id)) is not None else tray_id % 4
+            local_slot = change_filament_wire_ids(tray_id)[1]
             holder = next(
                 (ext for ext, slot in self.state.extruder_slots.items() if slot.holds(ams_id, local_slot)),
                 None,

+ 32 - 0
backend/tests/integration/test_printers_api.py

@@ -1381,6 +1381,38 @@ class TestAMSLoadUnloadAPI:
         response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=99")
         assert response.status_code == 400
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("tray_id", [128, 135])
+    async def test_load_an_ams_ht_slot(self, async_client: AsyncClient, printer_factory, tray_id):
+        """An AMS-HT tray is its unit id; refusing it made Load impossible there."""
+        printer = await printer_factory(name="H2C")
+        client = MagicMock()
+        client.ams_load_filament.return_value = True
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id={tray_id}")
+
+        assert response.status_code == 200, response.text
+        client.ams_load_filament.assert_called_once_with(tray_id, extruder_id=None)
+        assert response.json()["message"] == f"Loading filament from AMS HT-{'A' if tray_id == 128 else 'H'}"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("tray_id", [127, 136, 512])
+    async def test_load_refuses_ids_next_to_the_ams_ht_range(self, async_client: AsyncClient, printer_factory, tray_id):
+        printer = await printer_factory(name="H2C")
+        client = MagicMock()
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id={tray_id}")
+
+        assert response.status_code == 400
+        assert "128..135 (AMS-HT)" in response.json()["detail"]
+        client.ams_load_filament.assert_not_called()
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_load_not_found(self, async_client: AsyncClient):

+ 11 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -2949,6 +2949,17 @@ class TestTrayNowDualNozzleH2DSnow(_H2DFixtureMixin):
         h2d_client._process_message(_ams_payload(2))
         assert h2d_client.state.tray_now == 2
 
+    @pytest.mark.parametrize("unit", [128, 129, 131, 135])
+    def test_a_load_from_an_ams_ht_resolves_to_that_unit(self, h2d_client, unit):
+        """An AMS-HT reports its one tray as tray_now '0'; slot 0 is what the
+        pending load expects, not unit % 4 (which put 129 on AMS 0 slot 1)."""
+        h2d_client.state.pending_tray_target = unit
+
+        h2d_client._process_message(_ams_payload(0))
+
+        assert h2d_client.state.tray_now == unit
+        assert h2d_client.state.pending_tray_target is None
+
     def test_snow_disambiguates_ams_ht_to_128(self, h2d_client):
         """snow ext[1]=AMS HT (128), left active, tray_now='0' → global 128."""
         # Snow: extruder 1 → AMS 128 slot 0

+ 3 - 1
backend/tests/unit/test_a2l_ams_lite_2619.py

@@ -251,12 +251,14 @@ class TestOutboundTranslation:
         assert p["filaments"][0]["tray_id"] == 66  # 26 (normalised) -> 66 (physical)
 
     def test_load_filament_target_and_ams(self):
+        """BambuStudio (command_ams_change_filament): for a unit id of 16 or
+        more the target is the unit id itself, not a global tray."""
         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
+        assert p["target"] == A2L_LITE_PHYSICAL_AMS_ID
 
     def test_unload_uses_physical_ams(self):
         client = _wired(_client())

+ 55 - 0
backend/tests/unit/test_ams_ht_load_unload.py

@@ -0,0 +1,55 @@
+"""Load and Unload work on an AMS-HT slot.
+
+An AMS-HT tray is numbered by its unit id (128-135), as the printer reports it
+in tray_now. The printer card sent unit*4+slot (512 for the first AMS-HT),
+which the load route refused, and the commands turned 128 into ams_id 32. They
+now send what BambuStudio sends: ams_id = the unit id, slot_id 0, target = the
+unit id.
+"""
+
+import json
+from unittest.mock import MagicMock
+
+import pytest
+
+from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+
+def _client(model: str = "O1C2") -> BambuMQTTClient:
+    client = BambuMQTTClient(ip_address="10.0.0.1", serial_number="H2C", access_code="c", model=model)
+    client._client = MagicMock()
+    client.state.connected = True
+    return client
+
+
+def _sent(client) -> dict:
+    return json.loads(client._client.publish.call_args[0][1])["print"]
+
+
+@pytest.mark.parametrize("unit", [128, 131, 135])
+def test_load_addresses_the_unit(unit):
+    client = _client()
+
+    assert client.ams_load_filament(unit) is True
+
+    sent = _sent(client)
+    assert (sent["ams_id"], sent["slot_id"], sent["target"]) == (unit, 0, unit)
+
+
+def test_a_regular_slot_is_unchanged():
+    client = _client()
+
+    assert client.ams_load_filament(6) is True
+
+    sent = _sent(client)
+    assert (sent["ams_id"], sent["slot_id"], sent["target"]) == (1, 2, 6)
+
+
+def test_unload_of_a_loaded_ams_ht_addresses_its_unit():
+    """tray_now 128 named ams_id 32, a unit the printer does not have."""
+    client = _client(model="X1C")
+    client.state.tray_now = 128
+
+    assert client.ams_unload_filament() is True
+
+    assert _sent(client)["ams_id"] == 128

+ 88 - 0
frontend/src/__tests__/pages/PrintersPageAmsLoadUnload.test.tsx

@@ -220,4 +220,92 @@ describe('PrintersPage - AMS load/unload (#891)', () => {
       expect(captured).toBe('254');
     });
   });
+
+  it('an AMS-HT slot loads and unloads by its unit id (128), not unit*4+slot', async () => {
+    const user = userEvent.setup();
+    const captured: Record<string, string | null> = {};
+    const htStatus = {
+      ...mockIdleStatusWithAms,
+      ams: [
+        {
+          ...mockIdleStatusWithAms.ams[0],
+          id: 128,
+          is_ams_ht: true,
+          serial_number: 'HT00',
+          module_type: 'n3s',
+          tray: [{ id: 0, ...baseTray }],
+        },
+      ],
+    };
+
+    server.use(
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json(htStatus)),
+      http.post('/api/v1/printers/:id/ams/load', ({ request }) => {
+        captured.load = new URL(request.url).searchParams.get('tray_id');
+        return HttpResponse.json({ success: true, message: 'Loading filament from AMS HT-A' });
+      }),
+      http.post('/api/v1/printers/:id/ams/unload', ({ request }) => {
+        captured.unload = new URL(request.url).searchParams.get('tray_id');
+        return HttpResponse.json({ success: true, message: 'Unloading filament' });
+      }),
+    );
+
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      expect(screen.getAllByTestId('filament-slot').length).toBeGreaterThan(0);
+    });
+
+    await hoverSlot(screen.getAllByTestId('filament-slot')[0]);
+    await user.click(screen.getByText('Load'));
+    await waitFor(() => expect(captured.load).toBe('128'));
+
+    await hoverSlot(screen.getAllByTestId('filament-slot')[0]);
+    await user.click(screen.getByText('Unload'));
+    await waitFor(() => expect(captured.unload).toBe('128'));
+  });
+
+  it('with a Filament Track Switch, an AMS-HT slot asks which nozzle and sends it', async () => {
+    const user = userEvent.setup();
+    let captured: { tray_id: string | null; extruder_id: string | null } | null = null;
+    const htStatus = {
+      ...mockIdleStatusWithAms,
+      fila_switch: { installed: true, ready: true, in_slots: [-1, -1], out_extruders: [1, 0], stat: 0, info: 0 },
+      extruder_slots: {},
+      ams: [
+        {
+          ...mockIdleStatusWithAms.ams[0],
+          id: 128,
+          is_ams_ht: true,
+          serial_number: 'HT00',
+          module_type: 'n3s',
+          tray: [{ id: 0, ...baseTray }],
+        },
+      ],
+    };
+
+    server.use(
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json(htStatus)),
+      http.post('/api/v1/printers/:id/ams/load', ({ request }) => {
+        const url = new URL(request.url);
+        captured = { tray_id: url.searchParams.get('tray_id'), extruder_id: url.searchParams.get('extruder_id') };
+        return HttpResponse.json({ success: true, message: 'Loading filament from AMS HT-A' });
+      }),
+    );
+
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      expect(screen.getAllByTestId('filament-slot').length).toBeGreaterThan(0);
+    });
+
+    await hoverSlot(screen.getAllByTestId('filament-slot')[0]);
+    await user.click(screen.getByText('Load'));
+    await user.click(await screen.findByText('Left nozzle'));
+    await user.click(screen.getByText('Confirm'));
+
+    await waitFor(() => {
+      expect(captured).toEqual({ tray_id: '128', extruder_id: '1' });
+    });
+  });
 });

+ 2 - 1
frontend/src/api/client.ts

@@ -5319,7 +5319,8 @@ export const api = {
       { method: 'POST' }
     ),
 
-  // Load filament from a tray. trayId: 0-15 for AMS (amsId*4+slotId), 254 for external spool.
+  // Load filament from a tray. trayId: 0-15 for AMS (amsId*4+slotId), 128-135 for an
+  // AMS-HT (its unit id), 254 for external spool.
   // extruderId (0 = right, 1 = left) names the hotend to feed. Pass it only on a
   // printer with a Filament Track Switch — there the AMS is bound to a switch
   // inlet rather than a hotend, so the firmware cannot work the target out and

+ 7 - 15
frontend/src/pages/PrintersPage.tsx

@@ -3241,12 +3241,9 @@ function PrinterCard({
     // outright (254 = Ext-L, 255 = Ext-R). A switch cannot be fitted alongside
     // it anyway — Bambu's own guidance is to remove the switch to print from an
     // external spool, because it would occupy an extruder channel permanently.
-    //
-    // An AMS-HT slot skips the question for a blunter reason: the id this menu
-    // carries does not address an HT unit, so the load is refused whatever the
-    // answer. Asking first would only put a dialog in front of the same error.
+    // An AMS-HT behind the switch is asked like any AMS, as BambuStudio does.
     const isExternal = amsId === 255;
-    if (!ftsNeedsFeedDirection || isExternal || amsId >= 128) {
+    if (!ftsNeedsFeedDirection || isExternal) {
       loadAmsTrayMutation.mutate({ trayId });
       return;
     }
@@ -3655,13 +3652,6 @@ function PrinterCard({
     includeRfid?: boolean;
   }) => {
     const printerBusy = status?.state === 'RUNNING';
-    // An AMS-HT unit is addressed by its unit id alone (128-135), not by
-    // ams*4+slot, so the id this menu carries does not name it to the load and
-    // unload endpoints — they reject it. Sending no slot falls back to the
-    // printer-wide unload, which is what this menu did for every slot before
-    // the per-slot form existed. Load has never worked on an HT slot for the
-    // same addressing reason; fixing that is a separate change.
-    const unloadTrayId = amsId >= 128 && amsId !== 255 ? undefined : loadTrayId;
 
     return (
       <>
@@ -3710,7 +3700,7 @@ function PrinterCard({
           onClick={(e) => {
             e.stopPropagation();
             if (printerBusy || !hasPermission('printers:control')) return;
-            unloadAmsMutation.mutate({ trayId: unloadTrayId });
+            unloadAmsMutation.mutate({ trayId: loadTrayId });
           }}
           disabled={printerBusy || !hasPermission('printers:control')}
           title={printerBusy ? t('printers.bedJog.disabledWhilePrinting') : !hasPermission('printers:control') ? t('printers.permission.noControl') : undefined}
@@ -6117,7 +6107,8 @@ function PrinterCard({
                                     actions={renderAmsSlotActions({
                                       amsId: ams.id,
                                       slotId: htSlotId,
-                                      loadTrayId: ams.id * 4 + htSlotId,
+                                      // An AMS-HT holds one tray, numbered by its unit id (as tray_now).
+                                      loadTrayId: ams.id,
                                       isRefreshing: isHtRefreshing,
                                     })}
                                     spoolman={{
@@ -6233,7 +6224,8 @@ function PrinterCard({
                                     actions={renderAmsSlotActions({
                                       amsId: ams.id,
                                       slotId: htSlotId,
-                                      loadTrayId: ams.id * 4 + htSlotId,
+                                      // An AMS-HT holds one tray, numbered by its unit id (as tray_now).
+                                      loadTrayId: ams.id,
                                       isRefreshing: isHtRefreshing,
                                     })}
                                     configureSlot={{

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/AdditionalSection-BOgrberw.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/AmsUnitCard-CQpqKENa.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ArchivesPage-5Jmgxy5i.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/AssignToAmsModal-DEfyNw7q.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/CamWallPage-BE2Z4tti.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/CameraDiagnoseModal-CdYuChg8.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/CameraPage-DVUKNaO5.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/CameraWall-Bsfqfb9Q.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ColumnConfigModal-D1k223Hh.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ConfigureAmsSlotModal-CJh5MPWm.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ConnectAuthorizePage-Dbyhz4gk.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ContextMenu-ujcH62Z3.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ExternalLinkPage-B61TtAO8.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 1
static/assets/FileManagerPage-C1-0cUp8.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/FileUploadModal-ByBnFAi0.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/FinancePage-DNp3Qbe0.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/GCodeViewerPage-D53rH9BU.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/GroupEditPage-BIVxIPg-.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ImagePreviewModal--0-2GG08.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/InventoryPage-B9y3EjxU.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/LibraryTrashPage-Br3_o-jV.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/LoginPage-Dhds2WsE.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/MaintenancePage-Bqc2qiUi.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ModelSourcesPage-DUvT0hO1.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/NotificationsPage-D4ucPiUo.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 1
static/assets/PdfPreviewModal-CJcTx6-v.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/PhotoGalleryModal-CwNMwtev.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/PrinterLocationsPage-oWYMgF_L.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/PrintersPage-BEdXwOSn.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ProfilesPage-CuKL4VW9.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ProjectDetailPage-DwZCHiXU.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/ProjectsPage-BkHB6VSK.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/QueuePage-qy7-9qls.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/RichTextEditor-DPwmDlQ2.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/SettingsPage-CLe1k-pV.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/SetupPage-_XXeZzw4.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/SliceModal-J4x0-Nhc.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/SpoolBuddyAmsPage-CfNjFFS2.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-DQIvapoi.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/SpoolBuddyDashboard-26qsRUxy.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-CQMBdQKC.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 1
static/assets/SpoolBuddySettingsPage-DNyVTsjq.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-dKTLFvwH.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/SpreadsheetPreviewModal-BPJ33BJE.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/StatsPage-CrzPQqB3.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/StreamOverlayPage-DA073hUg.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/SystemInfoPage-C0qD7Rjc.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-C6-VhZuX.js


+ 1 - 1
static/assets/inventorySearch-CQDOs7S9.js → static/assets/inventorySearch-BijmL26U.js

@@ -1 +1 @@
-import{mt as e}from"./index-BtnlYHam.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{mt as e}from"./index-C6-VhZuX.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-Dt_perQo.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/pdf-CyA8sM9C.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/utils-Cro50Tx_.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-BtnlYHam.js"></script>
+    <script type="module" crossorigin src="/assets/index-C6-VhZuX.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-1U8CP3-A.js">
     <link rel="modulepreload" crossorigin href="/assets/QueryClientProvider-DNxj-7q3.js">

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است