fix(spoolman): decide spool assignability from the slot-assignment ledger, not extra.tag (#1122)

GET /spoolman/spools/unlinked hid any spool with a non-empty
  extra.tag from the AMS-slot assignment picker. extra.tag is only an
  RFID/NFC matching key -- OpenSpoolman writes its own NFC tag value
  into that same Spoolman field -- so every OpenSpoolman-tagged spool
  became un-assignable in Bambuddy even when it occupied no slot.

  get_unlinked_spools now determines assignability from the
  spoolman_slot_assignments table (the documented source of truth for
  slot assignments) and ignores extra.tag entirely. Both link_spool and
  the AMS auto-sync upsert a row there for every occupied slot, so the
  ledger is complete. get_linked_spools and find_spool_by_tag still use
  extra.tag -- they 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.

  Updates test_get_unlinked_spools_success and adds
  test_get_unlinked_spools_excludes_slot_assigned.
This commit is contained in:
maziggy
2026-05-21 09:33:47 +02:00
parent b06f8f6951
commit e1a236e408
3 changed files with 53 additions and 34 deletions
+2
View File
@@ -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.
+26 -19
View File
@@ -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
+25 -15
View File
@@ -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