diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d2a2bfa0..1a20c8b1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ All notable changes to Bambuddy will be documented in this file. - **Spoolman weight tracking now uses per-print grams for all spools, matching the internal Filament Inventory** ([#1119](https://github.com/maziggy/bambuddy/issues/1119), reported by @Moskito99) — Spoolman previously had two mutually-exclusive weight paths: AMS remain%×tray_weight auto-sync (default; only worked for Bambu Lab spools with valid RFID tray_weight) and per-print 3MF-grams tracking (only enabled when "Disable AMS Weight Sync" was toggled on). Non-BL spools without RFID fell through both paths — AMS auto-sync had no tray_weight to multiply, and the inventory_remaining fallback was wiped because activating Spoolman deletes the internal `spool_assignment` table — so Spoolman never saw a weight update for them. The internal Filament Inventory has no such gap: it always uses per-print 3MF grams as the primary path with AMS-remain% delta as fallback, and it works for every spool type. Spoolman now does the same: per-print tracking runs whenever Spoolman is enabled and is the only writer of `remaining_weight`. AMS auto-sync continues to maintain spool metadata and slot assignments but no longer touches weight (eliminating the double-count that would otherwise occur for BL spools with both paths active). `store_print_data` ([`spoolman_tracking.py:159`](backend/app/services/spoolman_tracking.py)) had its `disable_weight_sync` early-return removed; the three `sync_ams_tray` callsites (`main.py:1450` auto-sync, `spoolman.py:318` per-printer manual, `spoolman.py:517` sync-all) now hard-code `disable_weight_sync=True`. The `spoolman_disable_weight_sync` setting is now deprecated and a no-op — kept in the DB/UI for backwards compat. Behavioral consequence for existing users on the default flag (False): live AMS-based remaining_weight updates between prints stop happening; weight updates now arrive once per print completion with 3MF gram precision. Regression test in `test_spoolman_tracking.py::test_stores_tracking_when_disable_weight_sync_is_false` proves the early-return is gone. ### Fixed +- **Assigning a spool to an unconfigured AMS slot no longer silently skips MQTT on A1 Mini / P1S firmware — and the "PETG over a PLA-configured slot won't reconfigure" symptom is fixed in the same change** ([#1322](https://github.com/maziggy/bambuddy/issues/1322), reported by @RosdasHH) — On the user's A1 Mini BMCU (firmware 01.07.02.00) and P1S Standard AMS (firmware 00.00.06.75), pressing "Assign Spool" on any slot left the slot unconfigured: the DB row was created with `pending_config=True`, the MQTT publish was skipped, and the log line `Pre-configured assignment: ... (slot empty, will configure on insert)` fired even though the spool was physically loaded. The same code path also blocked the "swap PLA to PETG in the same slot" flow — Bambuddy would keep treating the spool as PLA because the publish never reached the printer. **Root cause:** the empty-slot detection at [`backend/app/api/routes/inventory.py:1267`](backend/app/api/routes/inventory.py) preferred `tray.state == 11` ("filament fed to extruder") over `tray_type`, falling back to `tray_type` only when `state` was missing entirely. Reporter's AMS dumps showed `state == 3` on every slot — configured and unconfigured, on both printers — and `state` was never absent. So the state-only branch always fired, the result was always "empty", and MQTT was always skipped regardless of whether the slot was actually loaded. The "fingerprint_type empty → defer until insert" pre-config replay at [`backend/app/main.py:1026`](backend/app/main.py) had the same `cur_state == 11` gate, so even when the user manually configured the slot in Bambu Studio afterward (making `tray_type` go from `""` to `"PLA"`), the deferred MQTT publish never fired because state stayed at 3. **Fix:** both call sites now use a disjunction — the slot is treated as loaded when **either** `state == 11` **or** `tray_type` is non-empty. The "Reset slot" case (state=11 + tray_type="") that the original state-only check was protecting still works through the first clause; the configured-slot case (state=3 + tray_type="PLA") on firmwares that never set state=11 now works through the second; and truly empty unconfigured slots (state≠11 + tray_type="") still fall through to the pending-config path correctly. The on_ams_change replay's disjunction also fires the deferred publish when the user later configures the slot through Bambu Studio, since that flips `tray_type` non-empty even if state stays at 3. **Caveat:** for a truly empty slot with a 3rd-party non-RFID spool that the user physically inserted, neither signal points to "loaded" on these firmwares, so we still can't auto-fire the publish until the slot gets configured (manually or by another assign). The pending-config row persists in the DB and gets applied on the next AMS push that flips `tray_type` non-empty. **Regression tests:** 3 in [`test_inventory_assign.py`](backend/tests/integration/test_inventory_assign.py) — `test_state_never_eleven_firmware_with_loaded_tray_fires_mqtt` (state=3 + tray_type='PLA' → MQTT fires; pins the reporter's primary symptom and the PETG-over-PLA secondary symptom which goes through the same predicate), `test_state_never_eleven_firmware_with_empty_tray_marks_pending` (state=3 + tray_type='' still pending — confirms the disjunction didn't accidentally turn truly empty slots into the loaded branch), and `test_on_ams_change_fires_replay_when_tray_type_appears_without_state_11` (pre-existing SpoolBuddy-style assignment with empty fingerprint; tray_type going `''→'PLA'` on a state=3 firmware fires the deferred publish even though state never becomes 11). All 28 tests in the file pass; ruff clean. + - **Assign Spool / Inventory search: numeric spool ID lookup is back, and Unassign in Spoolman mode no longer stays permanently disabled** ([#1336](https://github.com/maziggy/bambuddy/issues/1336), reported by @S0liter) — Two independent regressions surfaced from the same report. **(1) Numeric ID search:** typing a Spoolman spool's numeric ID into the search box on the "Assign Spool" dialog (or on the Inventory page) returned no results. The shared search helper `spoolMatchesQuery` at [`frontend/src/utils/inventorySearch.ts:7`](frontend/src/utils/inventorySearch.ts) only checked the text fields (`material`, `brand`, `color_name`, `subtype`, `note`, `slicer_filament_name`, `storage_location`) — the spool's `id` was not part of the predicate, so a query like `12` only matched when "12" happened to be a substring of one of the text fields. One-line fix: the predicate now also tests `String(spool.id).includes(q)`, mirroring the case-insensitive substring semantics of the other fields. Covers both call sites: the Assign Spool dialog ([`AssignSpoolModal.tsx:255`](frontend/src/components/AssignSpoolModal.tsx) for local inventory + `:446` for Spoolman) and the main Inventory page ([`InventoryPage.tsx:871`](frontend/src/pages/InventoryPage.tsx)). New regression test in [`__tests__/utils/inventorySearch.test.ts`](frontend/src/__tests__/utils/inventorySearch.test.ts) pins exact-match (`'42'` → id 42), substring (`'4'` → id 42), and non-match (`'99'` → id 42 rejected) so the predicate can't drift back into "text only" silently. **(2) Unassign button stuck disabled in Spoolman mode:** opening the edit modal on a Spoolman spool that was assigned to an AMS slot left the Unassign button greyed out — the user had no way to release the spool back to "available". The modal at [`SpoolFormModal.tsx:526`](frontend/src/components/SpoolFormModal.tsx) only ever queried `api.getAssignments()` (the legacy local `spool_assignments` table) and looked up by `a.spool_id === spool.id`. In Spoolman mode the slot assignment lives in the separate `spoolman_slot_assignments` table, keyed by `spoolman_spool_id` — so the lookup always returned `undefined`, the button's `disabled={isPending || !spoolAssignment}` predicate stayed true forever, and `unassignMutation` was also pointing at the wrong API (`unassignSpool` instead of `unassignSpoolmanSlot`). Both the query and the mutation now branch on the existing `spoolmanMode` prop: Spoolman mode uses `getSpoolmanSlotAssignments()` + lookup by `spoolman_spool_id` + `unassignSpoolmanSlot(spool.id)` and invalidates the `spoolman-slot-assignments-all` / `spoolman-slot-assignments` query keys; local mode keeps the existing path unchanged. Two new regression tests in [`__tests__/components/SpoolFormModal.test.tsx`](frontend/src/__tests__/components/SpoolFormModal.test.tsx) (`SpoolFormModal — Unassign button (#1336)`): the button is enabled and clicking it calls `unassignSpoolmanSlot(42)` when a matching `spoolman_slot_assignment` exists, and the button stays disabled (no `unassignSpool` fallback) when no assignment exists. All 12 search-helper tests + 13 InventoryPage search tests + 27 SpoolFormModal tests pass; frontend build clean. - **Spoolman auto-create no longer labels Bambu Lab RFID spools with competitor names like "3DXTECH™ Black"** ([#1309](https://github.com/maziggy/bambuddy/issues/1309), [PR #1330](https://github.com/maziggy/bambuddy/pull/1330) by @ojimpo) — When Bambuddy auto-created a Spoolman filament entry for a Bambu Lab RFID spool, the second-stage lookup against Spoolman's external library (`GET /api/v1/external/filament`, served from [SpoolmanDB](https://donkie.github.io/SpoolmanDB/filaments.json)) matched on `material + color_hex` only — there was no `manufacturer` / `vendor` filter. **The catalog is multi-vendor and roughly ID-sorted**: for PLA + `#000000` (black) it contains 64 entries, with the first hit being `3djake_pla_black_1000_175_n` (`3DJAKE`), the third being `3dxtech_pla_carbonxcarbonfiberblack_500_175_p` (`3DXTECH`, name `CarbonX™ Carbon Fiber Black`), and the actual `bambulab_pla_black_1000_175_n` not surfacing until position 15. Bambuddy therefore created the filament under the Bambu Lab vendor but labeled it with a competitor's product name. Real-world observations in production: Bambu Lab ABS Black created as `3DXTECH™ Black`, Bambu Lab PLA Support picked the adjacent / wrong variant instead of `bambulab_pla_supportforpla/petgblack_500_175_n`, and PLA Basic Black created as `PLA` (material, not `PLA Basic`). A **secondary issue compounded this**: `_create_filament_from_external` dropped the external entry's `density` field, so even when the correct entry was eventually picked the density got overwritten by `create_filament`'s built-in PLA-default 1.24 fallback instead of the catalog's actual value (1.26 for PLA Basic, 1.31 for PETG, etc.). **Fix** in [`backend/app/services/spoolman.py::_find_or_create_filament`](backend/app/services/spoolman.py): (1) the external-library loop now filters by `manufacturer == "Bambu Lab"` (case-insensitive, whitespace-trimmed), with a defensive `id.startswith("bambulab_")` fallback that handles entries where the `manufacturer` field is missing or has drifted in a future SpoolmanDB schema. (2) When multiple Bambu Lab candidates match the same `material + color_hex`, the function prefers the entry whose `name` equals the AMS `tray_sub_brands` (lowercase+strip comparison) so the more specific variant wins — `PLA Basic` over generic `Black`, `Support for PLA/PETG Black` over generic `Black`, etc. (3) `_create_filament_from_external` now propagates `external.get("density")` through to `create_filament`; when the catalog entry has no density set, the existing material-table fallback inside `create_filament` still kicks in via the `if density is None` branch at line 321 — no path lost. **Behavioural caveat the user needs to know**: previously-created mis-named filaments are NOT auto-renamed by this fix. Step 1 of `_find_or_create_filament` is the internal-Spoolman-filament loop that short-circuits on `(vendor == "Bambu Lab", material, color_hex)` — and that path is unchanged. Any Bambu Lab filament created by an older Bambuddy build (or hand-edited by the user) will continue to be matched and reused on subsequent AMS reads, regardless of how wrong its name is. To pick up the corrected name, the user has to **delete the mis-named filament in Spoolman once** — then the next AMS read for the same material+color falls through to the external-library step and creates a new entry with the correct Bambu Lab name. This is deliberate: some users may have intentionally renamed Bambu Lab filaments (e.g. to follow their own naming convention or to merge variants) and a silent auto-rename would undo that. **Regression tests** in [`test_spoolman_service.py::TestFindOrCreateFilament`](backend/tests/unit/services/test_spoolman_service.py) (6 new): internal short-circuit preserves the existing match without touching the external library, non-Bambu-Lab external entries are skipped even when they sort first in SpoolmanDB, `PLA Basic` wins over generic `Black` via the `tray_sub_brands` tiebreaker (per maintainer request on #1309), no-match-anywhere falls back to `tray_sub_brands or tray_type` instead of leaking a competitor name into the create call, `id.startswith("bambulab_")` accepts entries with absent `manufacturer` field, and density propagates end-to-end through the public method instead of getting clobbered by the material-default. All 44 tests in `test_spoolman_service.py` pass; ruff clean. **Reported and patched by @ojimpo.** diff --git a/backend/app/api/routes/inventory.py b/backend/app/api/routes/inventory.py index d139dfaac..a8fac072f 100644 --- a/backend/app/api/routes/inventory.py +++ b/backend/app/api/routes/inventory.py @@ -1255,17 +1255,23 @@ async def assign_spool( # inserted later, on_ams_change re-fires the full configuration. This is # the SpoolBuddy primary workflow: weigh-then-assign before insertion. # - # Empty-detection: prefer the printer's tray.state when it's reported - # (11=loaded, 9=empty, 10=spool present but filament not in feeder). - # tray_type alone is wrong post-"Reset slot" — that flow clears tray_type - # to "" while leaving filament physically loaded, and the old check - # would then mark it as a pending-config SpoolBuddy assignment, skip - # MQTT, and the slot would stay unconfigured forever because the - # on_ams_change replay only fires on an empty→loaded transition that - # never comes (the slot is already loaded). When state is not reported - # (older firmware), fall back to the tray_type heuristic. - if tray_state is not None: - slot_is_empty = tray_state != 11 + # Empty-detection: priority order is the firmware's explicit signals + # first, then a tray_type fallback for firmwares that don't use the full + # state enum meaningfully. + # - state == 9 → empty (firmware says "no spool") + # - state == 10 → empty (firmware says "spool present but no feed") + # - state == 11 → loaded (firmware says "filament in extruder") + # - any other state value, including 3 (A1 Mini BMCU / P1S Standard AMS + # always report 3) or None (older firmwares omit the field), falls + # back to tray_type: configured ⇒ loaded, empty ⇒ empty (#1322). + # The tray_type fallback for state==3 fixes the reported bug. The 9/10 + # branch keeps existing behavior intact even if the MQTT relay's + # auto-clearing of tray_type ever fails to fire — the firmware's + # explicit "empty" signal stays authoritative over stale metadata. + if tray_state == 9 or tray_state == 10: + slot_is_empty = True + elif tray_state == 11: + slot_is_empty = False else: slot_is_empty = not (fingerprint_type and fingerprint_type.strip()) configured = False diff --git a/backend/app/main.py b/backend/app/main.py index 6224993fd..4baefa99e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1018,12 +1018,18 @@ async def on_ams_change(printer_id: int, ams_data: list): # MQTT was deferred). The moment any filament gets inserted # — Bambu RFID, 3rd-party, or even an existing-but-now- # reconfigured spool — fire the deferred configuration. - # The "loaded" signal is `state == 11` (Bambu's "filament fed - # to extruder" code), NOT tray_type — 3rd-party spools without - # readable RFID report state=11 but tray_type="" because the - # AMS sensor reads no filament metadata. Requiring a non-empty - # tray_type would lock out the exact users this feature targets. - if not fp_type.strip() and cur_state == 11 and assignment.spool: + # The "loaded" signal is state == 11 (Bambu's "filament fed to + # extruder" code) OR, on firmwares that don't use the state + # enum meaningfully, a non-empty tray_type when state is + # NOT one of the firmware's explicit empty signals (9, 10). + # state-only was wrong for firmwares that never set 11 — A1 + # Mini BMCU 01.07.02.00 and P1S Standard AMS 00.00.06.75 both + # always report state=3 — so the replay never fired for them + # (#1322). The state ∉ {9,10} guard keeps the firmware's + # explicit "empty" signals authoritative over any stale + # tray_type that might survive the relay's auto-clearing. + loaded = cur_state == 11 or (cur_state not in (9, 10) and cur_type.strip()) + if not fp_type.strip() and loaded and assignment.spool: try: from backend.app.api.routes.inventory import ( apply_spool_to_slot_via_mqtt, diff --git a/backend/tests/integration/test_inventory_assign.py b/backend/tests/integration/test_inventory_assign.py index d890d05ad..f50e16fef 100644 --- a/backend/tests/integration/test_inventory_assign.py +++ b/backend/tests/integration/test_inventory_assign.py @@ -797,6 +797,75 @@ class TestAssignSpoolEmptySlotPreConfig: # Fingerprint was already set — re-fire path skipped mock_client.ams_set_filament_setting.assert_not_called() + @pytest.mark.asyncio + @pytest.mark.integration + async def test_on_ams_change_fires_replay_when_tray_type_appears_without_state_11( + self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession + ): + """A1 Mini / P1S firmware variant of the SpoolBuddy pre-config replay + (#1322). The user pre-assigned via SpoolBuddy (fingerprint empty), then + configured the slot manually in Bambu Studio so tray_type went from '' + to 'PLA' — but state stays at 3 because these firmwares never set it + to 11. With state-only detection the replay never fired.""" + from unittest.mock import AsyncMock + + from backend.app.main import on_ams_change + from backend.app.models.spool_assignment import SpoolAssignment + + printer = await printer_factory(name="A1 mini") + spool = await spool_factory(slicer_filament="GFL05", material="PLA") + + pre_assignment = SpoolAssignment( + spool_id=spool.id, + printer_id=printer.id, + ams_id=0, + tray_id=3, + fingerprint_color=None, + fingerprint_type=None, + ) + db_session.add(pre_assignment) + await db_session.commit() + + # state=3 (never goes to 11 on A1 Mini BMCU 01.07.02.00) but tray_type + # is now configured — the replay must fire on this transition too. + ams_data = [ + { + "id": 0, + "tray": [{"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 3, "tray_info_idx": "GFL05"}], + } + ] + + mock_client = MagicMock() + mock_client.ams_set_filament_setting.return_value = True + mock_client.extrusion_cali_sel.return_value = True + + status = _make_mock_status(ams_data=ams_data) + printer_info = MagicMock(name="A1 mini", serial_number="0309CA391800999") + + with ( + patch("backend.app.main.printer_manager") as mock_pm_main, + patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv, + patch("backend.app.main.mqtt_relay") as mock_relay, + patch("backend.app.main.ws_manager") as mock_ws, + ): + mock_pm_main.get_printer.return_value = printer_info + mock_pm_main.get_status.return_value = status + mock_pm_main.get_client.return_value = mock_client + mock_pm_main.get_model.return_value = "A1 mini" + mock_pm_inv.get_client.return_value = mock_client + mock_pm_inv.get_status.return_value = status + mock_relay.on_ams_change = AsyncMock() + mock_ws.send_printer_status = AsyncMock() + mock_ws.broadcast = AsyncMock() + + await on_ams_change(printer.id, ams_data) + + # Replay fired despite state never being 11 — the disjunction picked + # up tray_type going non-empty. + mock_client.ams_set_filament_setting.assert_called_once() + await db_session.refresh(pre_assignment) + assert pre_assignment.fingerprint_type == "PLA" + class TestAssignSpoolEmptyDetection: """Bambu firmware reports tray.state — 11=loaded, 9=empty, 10=spool present @@ -938,6 +1007,74 @@ class TestAssignSpoolEmptyDetection: body = response.json() assert body["pending_config"] is True + @pytest.mark.asyncio + @pytest.mark.integration + async def test_state_never_eleven_firmware_with_loaded_tray_fires_mqtt( + self, async_client: AsyncClient, printer_factory, spool_factory + ): + """A1 Mini BMCU 01.07.02.00 and P1S Standard AMS 00.00.06.75 always + report tray.state=3, never 11 — even for fully-loaded configured slots. + A state-only check classified those as empty and skipped MQTT (#1322). + With the disjunctive check, tray_type='PLA' alone is enough to fire.""" + printer = await printer_factory() + spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA") + + mock_client = MagicMock() + mock_client.ams_set_filament_setting.return_value = True + mock_client.extrusion_cali_sel.return_value = True + + # state=3, tray_type non-empty — A1 Mini / P1S configured slot. + tray_data = {"id": 3, "state": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "tray_info_idx": "GFL99"} + status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}]) + + with patch("backend.app.services.printer_manager.printer_manager") as mock_pm: + mock_pm.get_client.return_value = mock_client + mock_pm.get_status.return_value = status + + response = await async_client.post( + "/api/v1/inventory/assignments", + json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3}, + ) + + assert response.status_code == 200 + mock_client.ams_set_filament_setting.assert_called_once() + body = response.json() + assert body["pending_config"] is False + assert body["configured"] is True + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_state_never_eleven_firmware_with_empty_tray_marks_pending( + self, async_client: AsyncClient, printer_factory, spool_factory + ): + """Same firmwares as above, but the slot is truly unconfigured + (tray_type=''). Neither signal points to 'loaded', so this should + still pending-config — the user has to configure or insert filament + before MQTT can fire. Pins that the disjunction didn't accidentally + flip empty slots into the loaded branch.""" + printer = await printer_factory() + spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA") + + mock_client = MagicMock() + mock_client.ams_set_filament_setting.return_value = True + + tray_data = {"id": 3, "state": 3, "tray_type": "", "tray_color": "00000000", "tray_info_idx": ""} + status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}]) + + with patch("backend.app.services.printer_manager.printer_manager") as mock_pm: + mock_pm.get_client.return_value = mock_client + mock_pm.get_status.return_value = status + + response = await async_client.post( + "/api/v1/inventory/assignments", + json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3}, + ) + + assert response.status_code == 200 + mock_client.ams_set_filament_setting.assert_not_called() + body = response.json() + assert body["pending_config"] is True + @pytest.mark.asyncio @pytest.mark.integration async def test_external_slot_state_loaded_with_empty_tray_type_fires_mqtt(