diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ea562f05..1ccce3458 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ All notable changes to Bambuddy will be documented in this file. - **PyJWT CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): permanently ignored in pip-audit** — Advisory is disputed by the PyJWT maintainers, with the advisory description literally noting *"this is disputed by the Supplier because the key length is chosen by the application that uses the library."* `fix_versions=[]` on the advisory confirms no PyJWT patch exists or will exist. Bambuddy is not affected: `backend/app/core/auth.py:184` auto-generates secrets via `secrets.token_urlsafe(64)` (~86 chars of entropy, far above any sane minimum) and the file-loaded path at `:177` rejects secrets shorter than 32 chars. Added a permanent `--ignore-vuln CVE-2025-45768` to `.github/workflows/security.yml` with an inline comment citing the file:line evidence so a future maintainer reviewing the ignore list sees why it's load-bearing. Also dropped the stale `--ignore-vuln CVE-2026-4539` for Pygments — Pygments has since shipped a patched version and the ignore is no longer load-bearing (verified: `pip-audit --ignore-vuln CVE-2025-45768` alone reports clean). ### Fixed +- **OpenSpoolman-tagged spools are now selectable in the AMS-slot assignment picker (#1122, reported by @mithkr)** — Reporter runs Bambuddy alongside OpenSpoolman. OpenSpoolman writes a generated NFC tag value into the Spoolman `spool.extra.tag` field; any spool it had tagged then never appeared in Bambuddy's "Select a spool" picker (`LinkSpoolModal`), so a spool that was *physically* unassigned could not be linked to an AMS tray. **Root cause**: `GET /spoolman/spools/unlinked` (`backend/app/api/routes/spoolman.py`) classified a spool as "linked, hide it" purely on *presence of a non-empty `extra.tag`*. That was a stale proxy. `extra.tag` is only an RFID/NFC *matching key* — both Bambuddy and OpenSpoolman write a tag identifier there, for the same purpose — and its presence says nothing about whether the spool occupies an AMS slot. Bambuddy already has a dedicated ledger for that: the `spoolman_slot_assignments` table, which `models/spoolman_slot_assignment.py` itself documents as "the source of truth for Spoolman slot assignments". **Fix**: `get_unlinked_spools` now decides assignability from that ledger — a spool is assignable iff its id is **not** in `spoolman_slot_assignments` — and ignores `extra.tag` entirely. Verified the ledger is complete: both `link_spool` (manual link) and the AMS auto-sync (`spoolman.py` slot-change persistence) upsert a row for every occupied slot, so nothing genuinely assigned can leak back into the picker. `get_linked_spools` and `find_spool_by_tag` keep using `extra.tag` unchanged — those are genuine tag-match maps and are unaffected. Internal-inventory mode needs no parallel change: it stores tags in its own DB with no Spoolman `extra` collision, so the OpenSpoolman conflict cannot occur there. Visible behavior change: a spool Bambuddy linked but whose slot assignment was later cleared now re-appears as assignable — correct, since it is genuinely re-linkable. **Tests**: `test_spoolman_api.py` — `test_get_unlinked_spools_success` now asserts a spool with a non-empty OpenSpoolman-style `extra.tag` but no slot row still appears in the picker; new `test_get_unlinked_spools_excludes_slot_assigned` seeds a `SpoolmanSlotAssignment` row and asserts that spool is excluded while a tagged-but-unassigned spool is included. 50 spoolman-API integration tests green; backend ruff clean. + - **Missing-spool-assignment notification no longer false-fires on every Spoolman-mode print (#1473, reported and root-caused by @ojimpo)** — Reporter on Spoolman mode (AMS 2 Pro, all four trays bound to Spoolman spools via the Assign-Spool UI) got a `print_missing_spool_assignment` notification on every print start — 13 false positives in 7 days — each flagging trays that were correctly bound. He traced it precisely: `backend/app/services/spool_assignment_notifications.py` queried only the legacy `SpoolAssignment` table, never `SpoolmanSlotAssignment`. In Spoolman mode the legacy table is empty (bindings live in `spoolman_slot_assignments`, the source-of-truth since #1119), so `assigned_global_trays` came back empty and every used tray was reported missing. Same class of miss as #1459 (the weight tracker also skipped `SpoolmanSlotAssignment`) and a [[feedback_inventory_modes_parity]] violation — a check present in both modes was wired only for legacy. **Fix**: the assigned-tray set is now the **union** of both tables — `SpoolAssignment` and `SpoolmanSlotAssignment` rows for the printer. Both expose `printer_id` / `ams_id` / `tray_id` in identical shape (verified against `models/spoolman_slot_assignment.py`, whose `ams_id` range 0-7 / 128-191 / 255 is fully covered by the existing `_global_tray_from_assignment()`), so the helper works on either unchanged. The union is strictly safe: it can only *add* assignments, so it never regresses legacy-mode behavior and never reports a genuinely-unassigned tray as covered. Scope note: this does not add RFID-`extra.tag` resolution (a tray bound purely via the loaded spool's RFID tag with no slot-assignment row) — that needs the Spoolman client and is a deeper change; the reported false positive is entirely covered by the union since the Assign-Spool UI writes `SpoolmanSlotAssignment`. **Tests**: 3 new in `test_spool_assignment_notifications.py` (the reporter's suggested cases) — Spoolman-only binding suppresses the notification; Spoolman partial coverage flags only the uncovered tray; mixed-mode (A1 legacy + A2 Spoolman) union covers all used trays. The test fake now routes `execute()` by target table so either mode can be exercised; the existing legacy-mode test still passes unchanged. 4 notification tests green; backend ruff clean. **Audit follow-up**: a sweep of every `SpoolAssignment` consumer confirmed the other internal-mode-only users (`usage_tracker.py`, `spool_tag_matcher.py`, `routes/inventory.py`) are correct — internal and Spoolman modes have parallel implementations by design — but surfaced an asymmetry in `routes/settings.py`: the Spoolman-mode toggle cleared `SpoolAssignment` when switching *on* but never cleared `SpoolmanSlotAssignment` when switching *off*, so stale Spoolman rows lingered. Harmless before, but now that the notification unions both tables those stale rows would wrongly count as "assigned" in internal mode and suppress a legitimate warning. Added the symmetric clear — switching back to internal mode now deletes `SpoolmanSlotAssignment` rows, mirroring the existing on-switch behavior. 1 integration test in `test_spoolman_slot_assignments.py::TestModeSwitchClearsAssignments` covers it; 23 slot-assignment + 45 settings/slot tests green. - **Local Profiles: the search bar no longer disappears when a query matches nothing (#1470, reported by @pwostran)** — Typing a query in Settings → Local Profiles that matched no preset made the search bar itself vanish, leaving the user unable to clear or edit the query without a full page refresh. Root cause in `frontend/src/components/LocalProfilesView.tsx`: the search bar was gated on `{totalCount > 0 && …}`, and `totalCount` is the sum of the *post-filter* `filaments` / `printers` / `processes` lengths — so the moment the query filtered every column to empty, `totalCount` hit 0 and the search bar unmounted along with the columns. The `totalCount === 0` "No local presets yet" empty state then took over, which also misleadingly implied nothing was imported. **Fix**: added `hasAnyPresets`, computed from the *pre-filter* preset counts (`presets?.filament/printer/process` lengths), and gated the search bar on that instead — it stays mounted as long as any preset exists, regardless of the query. The empty state is now split: `!hasAnyPresets` shows the genuine "No local presets yet" + import hint, while `hasAnyPresets && totalCount === 0` shows a new "No presets match your search" message (with a search icon) so the two cases are no longer conflated. New `noSearchResults` i18n key added with real translations in all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). **Tests**: 1 new in `LocalProfilesView.test.tsx` — types a non-matching query and asserts the search bar is still in the DOM, retains the typed value, and the no-matches message renders. 10 LocalProfilesView tests green; i18n parity 4859 keys × 8 locales; frontend build clean. diff --git a/backend/app/api/routes/spoolman.py b/backend/app/api/routes/spoolman.py index 78cfe3186..e5dace90d 100644 --- a/backend/app/api/routes/spoolman.py +++ b/backend/app/api/routes/spoolman.py @@ -655,7 +655,7 @@ async def get_unlinked_spools( db: AsyncSession = Depends(get_db), _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ), ): - """Get all Spoolman spools that don't have a tag (not linked to AMS).""" + """Get all Spoolman spools not currently assigned to an AMS slot.""" sm = await get_spoolman_settings(db) enabled, url = sm["enabled"], sm["url"] if not enabled: @@ -672,27 +672,34 @@ async def get_unlinked_spools( raise HTTPException(status_code=503, detail="Spoolman is not reachable") spools = await client.get_spools() - unlinked = [] + # A spool is "assignable" iff it does not currently occupy an AMS slot. + # Assignability is decided by the spoolman_slot_assignments ledger — NOT by + # the presence of extra.tag. extra.tag is only an RFID/NFC matching key, and + # OpenSpoolman writes its own NFC tag value into that same field (#1122); + # treating any non-empty extra.tag as "linked" hid every OpenSpoolman-tagged + # spool from this picker even when it occupied no slot. Both link_spool and + # the AMS auto-sync upsert a row here for every occupied slot, so the ledger + # is a complete record of what is actually assigned. + assigned_result = await db.execute(select(SpoolmanSlotAssignment.spoolman_spool_id)) + assigned_spool_ids = set(assigned_result.scalars().all()) + + unlinked = [] for spool in spools: - # Check if spool has a tag in extra field - extra = spool.get("extra", {}) or {} - tag = extra.get("tag", "") - # Remove quotes if present (JSON encoded string) and check if empty - clean_tag = tag.strip('"') if tag else "" - if not clean_tag: - filament = spool.get("filament", {}) or {} - unlinked.append( - UnlinkedSpool( - id=spool["id"], - filament_name=filament.get("name"), - filament_vendor=(filament.get("vendor") or {}).get("name"), - filament_material=filament.get("material"), - filament_color_hex=filament.get("color_hex"), - remaining_weight=spool.get("remaining_weight"), - location=spool.get("location"), - ) + if spool["id"] in assigned_spool_ids: + continue + filament = spool.get("filament", {}) or {} + unlinked.append( + UnlinkedSpool( + id=spool["id"], + filament_name=filament.get("name"), + filament_vendor=(filament.get("vendor") or {}).get("name"), + filament_material=filament.get("material"), + filament_color_hex=filament.get("color_hex"), + remaining_weight=spool.get("remaining_weight"), + location=spool.get("location"), ) + ) return unlinked diff --git a/backend/tests/integration/test_spoolman_api.py b/backend/tests/integration/test_spoolman_api.py index b08489a50..483919ff7 100644 --- a/backend/tests/integration/test_spoolman_api.py +++ b/backend/tests/integration/test_spoolman_api.py @@ -205,13 +205,18 @@ class TestSpoolmanAPI: async def test_get_unlinked_spools_success( self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client ): - """Verify get unlinked spools returns spools without tags.""" - # Mock spool without extra.tag (unlinked) + """A spool with no slot assignment is assignable even when extra.tag is set. + + #1122 — extra.tag is only an RFID/NFC matching key (OpenSpoolman writes + its own NFC tag value there too); it must NOT gate assignability. A spool + with a non-empty extra.tag but no spoolman_slot_assignments row still + appears in the picker. + """ mock_spool = { "id": 1, "remaining_weight": 800, "used_weight": 200, - "extra": {}, # No tag = unlinked + "extra": {"tag": '"04A1B2C3D4E5F6"'}, # OpenSpoolman-style NFC tag value "filament": { "id": 1, "name": "PLA Basic", @@ -232,35 +237,40 @@ class TestSpoolmanAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_get_unlinked_spools_excludes_linked( - self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client + async def test_get_unlinked_spools_excludes_slot_assigned( + self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client, printer_factory, db_session ): - """Verify linked spools (with tag) are excluded.""" - # Mock spool with extra.tag (linked) - mock_spool_linked = { + """Verify spools that currently occupy an AMS slot are excluded.""" + from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment + + printer = await printer_factory() + + # Spool 1 occupies a slot; spool 2 has an extra.tag but no slot row. + db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=1, spoolman_spool_id=1)) + await db_session.commit() + + mock_spool_assigned = { "id": 1, "remaining_weight": 800, "used_weight": 200, - "extra": {"tag": '"ABC123"'}, # Has tag = linked + "extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'}, "filament": {"id": 1, "name": "PLA Red", "material": "PLA", "color_hex": "FF0000"}, } - - # Mock spool without tag (unlinked) - mock_spool_unlinked = { + mock_spool_unassigned = { "id": 2, "remaining_weight": 900, "used_weight": 100, - "extra": {}, # No tag = unlinked + "extra": {"tag": '"04DEADBEEF1122"'}, # tagged but not slot-assigned "filament": {"id": 2, "name": "PLA Blue", "material": "PLA", "color_hex": "0000FF"}, } - mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool_linked, mock_spool_unlinked]) + mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool_assigned, mock_spool_unassigned]) response = await async_client.get("/api/v1/spoolman/spools/unlinked") assert response.status_code == 200 data = response.json() assert len(data) == 1 - assert data[0]["id"] == 2 # Only unlinked spool + assert data[0]["id"] == 2 # Only the spool not occupying a slot # ========================================================================= # Linked Spools Tests