mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user