Преглед изворни кода

fix(ams): AMS Lite shows its serial and firmware (issue #3348)

- get_version names the AMS Lite's module "ams_f1/<id>", which was not
  among the recognised AMS prefixes, so its serial and firmware were
  skipped: "-" in the AMS popup, empty in the status API, and two
  warnings on every connection. The prefix is added and the id goes
  through normalize_am_unit_id, so the A2L's unit 16 lands on 6.
- A label saved for a unit with no serial is kept under its position.
  Now that the AMS Lite has a serial, the inventory location columns
  fall back to that label as the printer card does, Clear removes both
  keys, and saving under the serial replaces the position-keyed label.
maziggy пре 6 часа
родитељ
комит
5eae043592

+ 1 - 0
CHANGELOG.md

@@ -175,6 +175,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
+- **An AMS Lite shows its serial number and firmware version (#3348, reported by @Spegeli)** — The printer reports both in its version answer, where the AMS Lite calls itself `ams_f1`. Bambuddy only knew the names of the other AMS models, so it skipped the AMS Lite's entry. The AMS popup showed "—" for both, the status API returned them empty, and the log warned on every connection that they were missing. They now come through on the A1, A1 Mini and A2L, including the A2L's AMS Lite, which the printer numbers differently. A name given to an AMS Lite before this, which was kept by its position because it had no serial number, still shows on the printer card and in the inventory's location column. Saving it again moves it to the serial number, so it follows the unit, and **Clear** now removes it; before, the old name came back after clearing.
 - **The queue's "View in File Manager" icon opens the file (#3336, reported by @Magical-Penguin)** — On a job added from the File Manager, the link icon next to the name went to `/library`, which is no page, so the screen stayed empty. It now opens the File Manager in the file's folder, with the file marked and scrolled into view, in the grid, list and column views. The folder is looked up when the link is opened, so a file moved since it was queued is still found. A deleted file, or one you can't see, shows a warning. The archive icon on the same cards opened the Archives page without pointing at the archive; it now marks the archive too, switches from the log or calendar view to the grid, and warns when the filters hide it. An archive tile on a project's page did the same and now opens its archive marked as well. Both look in a freshly loaded list, so an archive created since the page was last open is found. Wiki updated.
 - **An H2D Pro reports its chamber and can heat it, and so can an X1E (#3335, reported by @Spegeli)** — A printer stored as "H2D Pro", which is what discovery and the add-printer list store, was checked against the model lists as "H2D PRO", with the space kept, while the lists spell it "H2DPRO". So an H2D Pro got no chamber temperature on the printer card and in the API (Home Assistant included), setting its chamber temperature was refused, and queued prints got no chamber preheat or soak and never switched the airduct to heating, or back to cooling for a PLA print after ABS; the printer card did not offer its airduct toggle either. Every model check now compares the name without spaces, and the card lists the H2D Pro. Separately, the X1E was listed as having only a chamber sensor although it has a chamber heater (up to 60 °C): setting its chamber temperature was refused and the queue never preheated its chamber. It now does both; the X1E has no airduct flap, so none is switched.
 - **A print started over LAN is linked to its archive, and its print time is no longer short (#3327, reported by @Andrewdlec)** — A print sent from Bambu Studio over LAN, or started from Handy or the printer's screen, reports no job id, and printer status found a running print's archive only by that id, so `current_archive_id` stayed empty for the whole print and the printer card could not name the plate of a multi-plate file. Status now also finds the running print's archive as soon as it exists, also after a Bambuddy restart mid-print, and never names an archive that was deleted. The printer card's cover image and a reloaded skip-objects list of such a print are now read from the archived 3MF instead of being fetched from the busy printer again over FTPS (skip-objects only while Bambuddy knows which archive is the print's, never by name). Separately, an archive's `started_at` was the moment it was written, after the 3MF download, which can take a minute at the start of a print; the print time taken from it at completion was short by that much. It is now the moment the print was seen to start, also for an archive written without a 3MF.

+ 6 - 9
backend/app/api/routes/inventory.py

@@ -2193,13 +2193,11 @@ async def list_assignments(
 
     # Fetch all relevant AMS labels keyed by serial number
     all_serials = set(serial_map.values())
-    # Also include synthetic fallback keys for assignments without a known serial
-    synthetic_keys: dict[str, tuple[int, int]] = {}
+    # Also include the synthetic position keys: a label saved before the unit
+    # reported its serial (the AMS Lite before #3348) is still under one, and
+    # is shown until it is saved again, as on the printer card.
     for a in assignments:
-        if (a.printer_id, a.ams_id) not in serial_map:
-            synthetic = f"p{a.printer_id}a{a.ams_id}"
-            synthetic_keys[synthetic] = (a.printer_id, a.ams_id)
-            all_serials.add(synthetic)
+        all_serials.add(f"p{a.printer_id}a{a.ams_id}")
 
     label_by_serial: dict[str, str] = {}
     if all_serials:
@@ -2214,9 +2212,8 @@ async def list_assignments(
         sn = serial_map.get((a.printer_id, a.ams_id))
         if sn and sn in label_by_serial:
             resp.ams_label = label_by_serial[sn]
-        elif not sn:
-            synthetic = f"p{a.printer_id}a{a.ams_id}"
-            resp.ams_label = label_by_serial.get(synthetic)
+        else:
+            resp.ams_label = label_by_serial.get(f"p{a.printer_id}a{a.ams_id}")
         responses.append(resp)
 
     return responses

+ 12 - 9
backend/app/api/routes/printers.py

@@ -7,7 +7,7 @@ from pathlib import Path
 
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi.responses import FileResponse, Response
-from sqlalchemy import func, select
+from sqlalchemy import delete, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from starlette.background import BackgroundTask
 
@@ -3495,7 +3495,8 @@ async def save_ams_label(
 
     # Determine the serial key to store under
     stripped = body.ams_serial.strip() if body.ams_serial else ""
-    serial_key = stripped if stripped else f"p{printer_id}a{ams_id}"
+    position_key = f"p{printer_id}a{ams_id}"
+    serial_key = stripped if stripped else position_key
 
     result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == serial_key))
     existing = result.scalar_one_or_none()
@@ -3505,6 +3506,10 @@ async def save_ams_label(
         existing.ams_id = ams_id
     else:
         db.add(AmsLabel(ams_serial_number=serial_key, ams_id=ams_id, label=body.label))
+    # A unit that reports its serial only now (the AMS Lite before #3348) may
+    # still have its label under the position key; the serial's label replaces it.
+    if serial_key != position_key:
+        await db.execute(delete(AmsLabel).where(AmsLabel.ams_serial_number == position_key))
 
     await db.commit()
     return {"ams_id": ams_id, "label": body.label}
@@ -3519,15 +3524,13 @@ async def delete_ams_label(
     db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Delete the friendly name for a specific AMS unit, reverting to the auto label."""
+    # The position key too: a label saved before the unit reported its serial
+    # is shown as a fallback, and would otherwise come back once cleared.
     stripped = ams_serial.strip() if ams_serial else ""
-    serial_key = stripped if stripped else f"p{printer_id}a{ams_id}"
+    keys = {stripped, f"p{printer_id}a{ams_id}"} - {""}
 
-    result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == serial_key))
-    existing = result.scalar_one_or_none()
-
-    if existing:
-        await db.delete(existing)
-        await db.commit()
+    await db.execute(delete(AmsLabel).where(AmsLabel.ams_serial_number.in_(keys)))
+    await db.commit()
 
     return {"success": True}
 

+ 5 - 6
backend/app/api/routes/spoolman_inventory.py

@@ -1572,11 +1572,12 @@ async def get_all_spoolman_slot_assignments(
             except (ValueError, TypeError):
                 continue
 
-    # Add synthetic fallback key (f"p{pid}a{ams_id}") for slots without a serial.
+    # Add the synthetic position key (f"p{pid}a{ams_id}"): a label saved before
+    # the unit reported its serial (the AMS Lite before #3348) is still under
+    # one, and is shown until it is saved again, as on the printer card.
     all_serials: set[str] = set(serial_map.values())
     for s in slots:
-        if (s.printer_id, s.ams_id) not in serial_map:
-            all_serials.add(f"p{s.printer_id}a{s.ams_id}")
+        all_serials.add(f"p{s.printer_id}a{s.ams_id}")
 
     label_by_serial: dict[str, str] = {}
     if all_serials:
@@ -1588,9 +1589,7 @@ async def get_all_spoolman_slot_assignments(
         sn = serial_map.get((pid, ams_id))
         if sn and sn in label_by_serial:
             return label_by_serial[sn]
-        if not sn:
-            return label_by_serial.get(f"p{pid}a{ams_id}")
-        return None
+        return label_by_serial.get(f"p{pid}a{ams_id}")
 
     enriched: list[SpoolmanSlotAssignmentEnriched] = []
     for s in slots:

+ 1 - 1
backend/app/schemas/printer.py

@@ -247,7 +247,7 @@ class AMSUnit(BaseModel):
     dry_sf_reason: list[int] = []  # Cannot-dry reasons from firmware (see CannotDryReason)
     dry_target_temp: int | None = None  # Active-cycle target °C (Bambu doesn't echo this)
     dry_filament: str | None = None  # Active-cycle filament name we sent
-    module_type: str = ""  # "ams", "n3f", "n3s"
+    module_type: str = ""  # "ams", "ams_f1", "n3f", "n3s"
 
 
 class NozzleInfoResponse(BaseModel):

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

@@ -36,10 +36,11 @@ PENDING_TRAY_TARGET_TTL = 120.0
 
 # AMS module name prefixes used in get_version responses.
 # The numeric suffix after '/' is the AMS unit ID as reported in push_status.
-#   "ams/<id>"  – original AMS (X1C, X1E, P1S, …)
-#   "n3f/<id>"  – AMS 2 Pro (H2D Pro and similar)
-#   "n3s/<id>"  – AMS HT (H2D Pro and similar; IDs typically start at 128)
-_AMS_MODULE_PREFIXES = ("ams/", "n3f/", "n3s/")
+#   "ams/<id>"     – original AMS (X1C, X1E, P1S, …)
+#   "ams_f1/<id>"  – AMS Lite (A1, A1 Mini; the A2L reports id 16, stored as 6)
+#   "n3f/<id>"     – AMS 2 Pro (H2D Pro and similar)
+#   "n3s/<id>"     – AMS HT (H2D Pro and similar; IDs typically start at 128)
+_AMS_MODULE_PREFIXES = ("ams/", "ams_f1/", "n3f/", "n3s/")
 
 # gcode_state values that mean the printer is not idle and must not be handed a
 # new start-print (#2598). The firmware rejects a project_file while busy with
@@ -2708,6 +2709,7 @@ class BambuMQTTClient:
 
         AMS module naming conventions (numeric suffix is the AMS unit ID):
         - ``ams/<id>``  – original AMS
+        - ``ams_f1/<id>`` – AMS Lite (#3348)
         - ``n3f/<id>``  – AMS 2 Pro (H2D Pro and similar)
         - ``n3s/<id>``  – AMS HT (H2D Pro and similar)
 
@@ -2754,7 +2756,9 @@ class BambuMQTTClient:
             if not any(name.startswith(prefix) for prefix in _AMS_MODULE_PREFIXES):
                 continue
             try:
-                ams_id = int(name.split("/", 1)[1])
+                # The A2L's AMS Lite is unit 16 here but stored as 6, like
+                # everywhere else it is ingested.
+                ams_id = normalize_am_unit_id(int(name.split("/", 1)[1]))
             except (ValueError, IndexError):
                 continue
             sw_ver = module.get("sw_ver", "")

+ 103 - 0
backend/tests/integration/test_ams_label_position_key_3348.py

@@ -0,0 +1,103 @@
+"""An AMS label saved before its unit reported a serial (#3348).
+
+A label is kept under the unit's serial, or under the position key
+``p<printer>a<unit>`` while the unit reports none. The AMS Lite reported none
+until #3348, so its labels sit under the position key, and now the unit comes
+with a serial. The label must still show everywhere, be cleared by Clear, and
+move to the serial when it is saved again.
+"""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.models.ams_label import AmsLabel
+
+SERIAL = "AMSLITE0001"
+
+
+def _state():
+    return MagicMock(connected=True, raw_data={"ams": [{"id": 0, "sn": SERIAL}]})
+
+
+async def _labels(db_session):
+    rows = (await db_session.execute(select(AmsLabel))).scalars().all()
+    return {r.ams_serial_number: r.label for r in rows}
+
+
+@pytest.fixture
+async def printer(printer_factory, db_session):
+    printer = await printer_factory(model="A1")
+    db_session.add(AmsLabel(ams_serial_number=f"p{printer.id}a0", ams_id=0, label="Desk Lite"))
+    await db_session.commit()
+    return printer
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestPrinterCard:
+    async def test_still_shown(self, async_client: AsyncClient, printer):
+        with patch("backend.app.api.routes.printers.printer_manager") as pm:
+            pm.get_status.return_value = _state()
+            resp = await async_client.get(f"/api/v1/printers/{printer.id}/ams-labels")
+
+        assert resp.json() == {"0": "Desk Lite"}
+
+    async def test_clear_removes_it(self, async_client: AsyncClient, printer, db_session):
+        resp = await async_client.delete(f"/api/v1/printers/{printer.id}/ams-labels/0?ams_serial={SERIAL}")
+
+        assert resp.json() == {"success": True}
+        assert await _labels(db_session) == {}
+
+    async def test_saving_moves_it_to_the_serial(self, async_client: AsyncClient, printer, db_session):
+        await async_client.put(
+            f"/api/v1/printers/{printer.id}/ams-labels/0", json={"label": "Lite", "ams_serial": SERIAL}
+        )
+
+        assert await _labels(db_session) == {SERIAL: "Lite"}
+
+    async def test_saving_without_a_serial_keeps_the_position_key(self, async_client: AsyncClient, printer, db_session):
+        await async_client.put(f"/api/v1/printers/{printer.id}/ams-labels/0", json={"label": "Lite"})
+
+        assert await _labels(db_session) == {f"p{printer.id}a0": "Lite"}
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_inventory_location_shows_it(async_client: AsyncClient, printer, db_session):
+    from backend.app.models.spool import Spool
+    from backend.app.models.spool_assignment import SpoolAssignment
+
+    spool = Spool(material="PLA", brand="eSUN", rgba="E1E9E9FF", label_weight=1000, weight_used=0)
+    db_session.add(spool)
+    await db_session.commit()
+    await db_session.refresh(spool)
+    db_session.add(SpoolAssignment(spool_id=spool.id, printer_id=printer.id, ams_id=0, tray_id=1))
+    await db_session.commit()
+
+    with patch("backend.app.services.printer_manager.printer_manager.get_all_statuses") as statuses:
+        statuses.return_value = {printer.id: _state()}
+        resp = await async_client.get("/api/v1/inventory/assignments")
+
+    assert [a["ams_label"] for a in resp.json()] == ["Desk Lite"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_spoolman_location_shows_it(async_client: AsyncClient, printer, db_session):
+    from backend.app.models.settings import Settings
+    from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+
+    db_session.add_all(
+        [Settings(key="spoolman_enabled", value="true"), Settings(key="spoolman_url", value="http://localhost:7912")]
+    )
+    db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=1, spoolman_spool_id=216))
+    await db_session.commit()
+
+    with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm:
+        pm.get_all_statuses.return_value = {printer.id: _state()}
+        resp = await async_client.get("/api/v1/spoolman/inventory/slot-assignments/all")
+
+    assert [a["ams_label"] for a in resp.json()] == ["Desk Lite"]

+ 79 - 0
backend/tests/unit/services/test_ams_lite_version_info_3348.py

@@ -0,0 +1,79 @@
+"""The AMS Lite gets its serial number and firmware from get_version (#3348).
+
+Like every AMS, the AMS Lite reports both only in the printer's get_version
+answer, as a module of its own: "ams_f1/<id>". That prefix was not recognised,
+so the module was skipped and the unit showed neither. The module names and
+fields below are from real A1 and A2L captures (ha-bambulab mock data). The
+A2L reports its AMS Lite as unit 16, which is stored as 6.
+"""
+
+import pytest
+
+from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+
+@pytest.fixture
+def client():
+    return BambuMQTTClient(ip_address="192.168.1.100", serial_number="TESTAMSLITE", access_code="12345678")
+
+
+def _version(name, sw_ver="00.00.07.94", sn="AMSLITE0001"):
+    return {
+        "command": "get_version",
+        "module": [
+            {"name": "ota", "sw_ver": "01.08.01.00"},
+            {"name": name, "sw_ver": sw_ver, "hw_ver": "AMS_F102", "sn": sn},
+        ],
+    }
+
+
+def _push(unit_id):
+    return {"ams": [{"id": str(unit_id), "tray": [{"id": "0"}, {"id": "1"}, {"id": "2"}, {"id": "3"}]}]}
+
+
+def _unit(client, unit_id):
+    return next(u for u in client.state.raw_data["ams"] if int(u["id"]) == unit_id)
+
+
+@pytest.mark.parametrize(("module", "pushed_id", "stored_id"), [("ams_f1/0", 0, 0), ("ams_f1/16", 16, 6)])
+class TestAmsLite:
+    def test_version_after_the_ams_push(self, client, module, pushed_id, stored_id):
+        client._handle_ams_data(_push(pushed_id))
+
+        client._handle_version_info(_version(module))
+
+        unit = _unit(client, stored_id)
+        assert (unit["sn"], unit["sw_ver"], unit["module_type"]) == ("AMSLITE0001", "00.00.07.94", "ams_f1")
+
+    def test_version_before_the_first_ams_push(self, client, module, pushed_id, stored_id):
+        client._handle_version_info(_version(module))
+
+        client._handle_ams_data(_push(pushed_id))
+
+        unit = _unit(client, stored_id)
+        assert (unit["sn"], unit["sw_ver"]) == ("AMSLITE0001", "00.00.07.94")
+
+    def test_no_missing_version_warning(self, client, module, pushed_id, stored_id, caplog):
+        client._handle_ams_data(_push(pushed_id))
+
+        client._handle_version_info(_version(module))
+
+        assert "not available in version info" not in caplog.text
+
+
+def test_other_ams_modules_are_unchanged(client):
+    client._handle_ams_data(
+        {"ams": [{"id": "0", "tray": []}, {"id": "128", "tray": []}]},
+    )
+
+    client._handle_version_info(
+        {
+            "module": [
+                {"name": "n3f/0", "sw_ver": "03.00.21.29", "sn": "N3F0001"},
+                {"name": "n3s/128", "sw_ver": "03.00.21.30", "sn": "N3S0001"},
+            ]
+        }
+    )
+
+    assert (_unit(client, 0)["sn"], _unit(client, 0)["module_type"]) == ("N3F0001", "n3f")
+    assert (_unit(client, 128)["sn"], _unit(client, 128)["module_type"]) == ("N3S0001", "n3s")

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

@@ -526,7 +526,7 @@ export interface AMSUnit {
   dry_sf_reason: number[]; // Cannot-dry reasons (1=InsufficientPower, 8=NeedPluginPower)
   dry_target_temp: number | null; // Active-cycle target °C (Bambu does not echo)
   dry_filament: string | null;    // Active-cycle filament name we sent
-  module_type: string;    // "ams", "n3f", "n3s"
+  module_type: string;    // "ams", "ams_f1", "n3f", "n3s"
 }
 
 export interface ScheduledDrying {