feat(inventory): unified Spoolman inventory UI + AMS slot assignments + Storage Location + NFC write support#1114
Conversation
|
As discussed... docker pull ghcr.io/maziggy/bambuddy:spoolman-test |
c9b8c1f to
af49b8e
Compare
maziggy
left a comment
There was a problem hiding this comment.
Blockers
- GET /settings now leaks every secret to API-key callers
backend/app/core/auth.py:32-46 removed SETTINGS_READ from the denylist (so the kiosk can read the UI language). But backend/app/api/routes/settings.py:64-148 only blanks ldap_bind_password — every other secret is returned verbatim:
- mqtt_password
- ha_token (Home Assistant Long-Lived Access Token)
- prometheus_token
- virtual_printer_access_code
Any API key (including a stolen kiosk key) can now exfiltrate every printer access code, the broker password, and the HA + Prometheus tokens.
Fix: blank or strip these fields from the GET /settings response when the caller is an API key (or unconditionally — adopt the *_set: bool pattern the virtual-printer endpoint already uses). Add a regression test in test_auth_apikey_rbac.py asserting these fields are scrubbed.
- Permission downgrade lets non-admins reboot Pis and ship arbitrary code over SSH
backend/app/api/routes/spoolbuddy.py:1015-1041 (queue_system_command) and :1204-1245 (trigger_daemon_update) were swapped from SETTINGS_UPDATE to INVENTORY_UPDATE. INVENTORY_UPDATE is granted to anyone who can edit a spool, but these endpoints can:
- reboot / shutdown / restart_daemon / restart_browser on a Pi
- SSH into the Pi as the bambuddy backend user, git fetch && git reset --hard && pip install && systemctl restart whatever's on origin/branch (i.e. arbitrary code execution)
These are admin-tier system actions, not inventory operations.
Fix: revert both endpoints to SETTINGS_UPDATE, OR add a dedicated Permission.SPOOLBUDDY_DEVICE_CONTROL to _APIKEY_DENIED_PERMISSIONS. Add tests that an INVENTORY_UPDATE-only key gets 403 on these two endpoints, and an admin gets 200.
- TOCTOU race in get_spoolman_slot_assignment cleanup deletes a freshly re-assigned slot
backend/app/api/routes/spoolman_inventory.py:680-683 deletes by slot.id only. The upsert in assign_spoolman_slot (:596-609) keeps the same row id, only changing
spoolman_spool_id. If a concurrent assign re-points the same slot at a valid spool while this GET is awaiting Spoolman's 404, the cleanup deletes the new, valid assignment.
Fix:
delete(SpoolmanSlotAssignment).where(
SpoolmanSlotAssignment.id == slot.id,
SpoolmanSlotAssignment.spoolman_spool_id == slot.spoolman_spool_id,
)
Plus an asyncio.gather integration test driving the race.
- update_spool and four siblings still silently return None on Exception
backend/app/services/spoolman.py:267-268, 364-368, 412-417, 458-459, 752-753. The PR modernized get_spool / update_spool_full / get_all_spools / delete_spool /
set_spool_archived to raise typed exceptions. It did NOT modernize update_spool, create_spool, create_filament, create_vendor, use_spool. Consequences:
- sync_ams_tray:1145 reports a 5xx/timeout/auth failure as "Spool not found in Spoolman" — wrong cause.
- bulk_create_spools returns failed_count: N with no per-failure reason.
Fix: route these five through the typed _request_spool helper. Update sync_ams_tray and bulk_create_spools to surface the real exception classes.
- SSRF rejection of Spoolman URL returns HTTP 503 instead of 400
backend/app/api/routes/spoolman.py:124-140. init_spoolman_client(url) raises ValueError from the SSRF guard, but the bare except Exception reports it as 503 "Spoolman is unreachable". Users typing http://169.254.169.254/ will retry forever, suspect networking, never realise the URL is being actively blocked.
Fix: add except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc BEFORE the broad except. Add from e to the existing 503 raise.
- Slot-assignment endpoints commit without rollback on failure
spoolman_inventory.py:595-610, 628-631, 680-683 all commit with no try/except: await db.rollback(). link_spool and unlink_spool in spoolman.py:797-810 and :851-854 do have proper rollbacks — these three siblings don't. On commit failure (DB lock, integrity error mid-statement) the session ends in an aborted state; reuse fails.
Fix: wrap each commit in the same try/except: await db.rollback(); raise HTTPException(...) pattern link_spool already uses.
Important — should fix in same PR
- _translate_spoolman_errors propagates Spoolman 4xx as Bambuddy 4xx (spoolman_inventory.py:113-114). A Spoolman 401 makes the frontend interceptor force-log-out. Map all SpoolmanClientError to 502 Bad Gateway with {"detail": ..., "upstream_status": exc.status_code}. Capture exc.response.text (truncated to ~200 chars) so 422 validation errors surface the actual field message.
- weight_grams accepts NaN/Infinity (schemas/spoolbuddy.py:108-117). Pydantic float still accepts +Inf/-Inf/NaN — these blow up Spoolman PATCH (non-standard JSON) and
Integer column writes (OverflowError → 500). Add a field_validator rejecting non-finite values. Also: only ScaleReadingRequest needs to be unbounded (raw ADC);
UpdateSpoolWeightRequest.weight_grams should re-add ge=0.0, le=100_000.0 since it's always calibrated grams feeding Spoolman / DB writes. - unlink_spool writes '""' sentinel into extra.tag instead of removing the key (spoolman.py:838-839). The PATCH path correctly uses cur_extra.pop("tag", None); the
dedicated unlink path leaves extra = {"tag": """"} visible in the Spoolman UI. Use the same pop pattern under the per-spool extra lock, or extend merge_spool_extra to drop
a key when the value is None. - update_spool_weight ignores data_origin (spoolbuddy.py:769-809). The check is if sm_client is not None, not if spool.data_origin == "spoolman". Local-DB code path is
unreachable when Spoolman is enabled, even for local-DB-only spools. NFC scan (:486-491) correctly tries local first; weight sync should mirror that. - Bulk create returns failed_count: N with no per-failure reason (spoolman_inventory.py:347-371). Same fix as B4 — capture per-iteration exceptions in the 207 body.
- _apply_price_if_set swallows all HTTPExceptions silently (spoolman_inventory.py:119-137). Spool created, price PATCH fails, user gets HTTP 200 with no indication price wasn't saved. Either re-raise or return 207 with warnings: ["price_not_saved: "].
- assign_spoolman_slot doesn't bounds-check ams_id/tray_id (spoolman_inventory.py:570-612). Schema only checks >=0. A stale UI or malicious POST creates a permanently-orphaned assignment. Add 0 <= ams_id <= 7 / 0 <= tray_id <= 3 (or look up the printer's actual layout from the registry).
- _get_spoolman_client_or_none silently disables Spoolman on SSRF rejection (spoolbuddy.py:55-91). Logs a warning and returns None. Every NFC scan / write / weight update silently behaves as if Spoolman were off, while the frontend status indicator may still show "connected". Either raise 503 or emit a spoolman_misconfigured WS event.
- nfc_tag_scanned Spoolman outage indistinguishable from "tag not registered" (spoolbuddy.py:411-428). Both return {matched: False, spool_id: None} — user scanning an unregistered tag during an outage gets zero feedback. Distinguish via a spoolman_unavailable status field.
- Unhandled SpoolmanClientError in link_spool/unlink_spool → opaque 500 (spoolman.py:775-780, 838-843). 401/403/422 from Spoolman becomes Internal Server Error. Add a SpoolmanClientError handler.
- core_weight accepted in POST/PATCH but silently dropped (spoolman_inventory.py:170-204). Comment says "schema parity but not persisted". User sends core_weight=400, gets 200, downstream calculations use 250. Either persist it (it lives on Spoolman's filament.spool_weight) or reject any non-default value with 400.
- Test gaps for the blockers above:
- No test that GET /settings scrubs sensitive fields when called via API key.
- No negative test that an INVENTORY_READ-only API key is rejected on /devices/{id}/system/command and /update.
- No concurrency test for POST /slot-assignments (covers both the upsert and the cleanup race).
- No PG-dialect string test for spoolman_slot_assignments table DDL (parallel to existing tests for active_print_spoolman and spool_usage_history).
- No direct unit tests for assert_safe_spoolman_url — security boundary, deserves a focused unit suite covering userinfo URLs, IPv4-mapped IPv6, IDN, missing hostname,
port 0.
- No concurrent test for merge_spool_extra lock (the lock is the entire correctness guarantee for parallel NFC writes; B7 only verifies in-lock refetch, not contention).
- AssignSpoolModal.test.tsx never sets spoolmanEnabled=true — the Spoolman-mode picker is untested, and the #1133 archived-filter fix could regress silently.
- InventoryPageDeepLink.test.tsx covers internal-mode only.
- InventoryPageSearch.test.ts reimplements the filter inline rather than testing the production code — extract buildSearchPredicate as a named export.
Nits (style / polish, group fix)
- Multi-paragraph docstrings all over the new code violate the project's "one short line max" rule: assert_safe_spoolman_url (24 lines), _map_spoolman_spool (5 lines, 2
paragraphs), 7+ Spoolman client GET-helpers, 4 inventory route handlers, _translate_spoolman_errors, update_spool_full (23 lines). - Historical-state phrasing in auth.py:30-32: "which was always permitted before the denylist was introduced" — drop the trailing clause.
-
fall through to broadcast + raise 502 below triplicated in spoolbuddy.py:626, 632, 638.
- About ten WHAT comments restating the next line ("Capture data_origin before clearing", "Find the device first", "Try local DB first"). Drop them.
- SkippedSpool.reason is free-form English — make it Literal["no_rfid_no_assignment"] and add structured printer_name/ams_id/tray_id so the UI doesn't string-parse location.
The color field uses RRGGBB while everywhere else in the codebase uses RRGGBBAA — align. - _map_spoolman_spool returns dict while MappedSpoolFields TypedDict in opentag3d.py:25 describes a subset of the same shape. Promote one shared TypedDict and import it from both modules.
- SpoolmanSlotAssignment model: name the UniqueConstraint (e.g. uq_spoolman_slot_printer_ams_tray), add index=True to printer_id and spoolman_spool_id (the latter is queried by unassign_spoolman_slot's WHERE), and add CheckConstraints on the numeric ranges so direct DB writes can't bypass the Pydantic checks. (ON CONFLICT(cols) is portable Postgres — naming the constraint is hygiene, not portability.)
- TS getSpoolmanSlotAssignments has an inline anonymous type at client.ts:4321 — hoist to SpoolmanSlotAssignmentRow. getSpoolmanSlotAssignment (singular) is typed
InventorySpool | null but the unassign endpoint returns {"id": int} for the "spool gone" branch — type mismatch. - _extra_locks dict in SpoolmanClient grows unbounded over the process lifetime. Use WeakValueDictionary or evict on release.
- Inventory-weight load failure logs at main.py:1250, spoolman.py:254, 423 are still logger.debug — the slot-map promotion to warning (H1) missed these parallel sites.
- opentag3d rgba parse fallback silently encodes solid black on malformed input. Add a one-line logger.warning.
a302418 to
004fa3b
Compare
️✅ There are no secrets present in this pull request anymore.If these secrets were true positive and are still valid, we highly recommend you to revoke them. 🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request. |
df61594 to
fa96ffb
Compare
…MS deep-link + SpoolBuddy NFC write support (maziggy#1063) feat(inventory): replace Spoolman iframe with internal inventory UI When Spoolman is enabled, the Inventory page now uses the same internal UI (spool list, create/edit modal, archive, delete, weight sync) backed by a new proxy layer instead of opening an iframe.
The SSRF guard added in this PR rejected all RFC-1918 private and loopback
addresses, which breaks Bambuddy's primary deployment topology — Spoolman
running on the same LAN as Bambuddy (192.168.x.x, 10.x.x.x, 127.0.0.1).
Users hit "Spoolman URL must not point to a private, loopback, link-local,
multicast, or unspecified address" on legitimate setups.
Rescope the guard to block what's actually dangerous in this context:
cloud metadata endpoints (AWS/Alibaba IMDS), multicast, unspecified,
non-http(s) schemes, and numeric-encoded IP bypasses. Loopback and
RFC-1918 ranges are now explicitly permitted.
Tests:
- test_ssrf_blocked_schemes_and_addresses updated with refined block list
- test_ssrf_allows_lan_spoolman_topologies (new) asserts loopback +
RFC-1918 are accepted so this regression cannot recur silently
- TestSpoolmanInventorySSRFSpoolBuddyPath parametrize lists trimmed
…ndpoint The queue_system_command endpoint (reboot/shutdown/restart_daemon/restart_browser) required SETTINGS_UPDATE, which is blocked for all API keys via _APIKEY_DENIED_PERMISSIONS. This prevented the SpoolBuddy kiosk (authenticated via API key) from triggering shutdown/reboot from the display. Changed to INVENTORY_UPDATE, consistent with all other SpoolBuddy endpoints, and accessible to both API keys and Operator-role users.
…dpoint
The trigger_daemon_update endpoint (POST /devices/{id}/update) required
SETTINGS_UPDATE, which is blocked for all API keys via _APIKEY_DENIED_PERMISSIONS.
This prevented the SpoolBuddy kiosk from triggering software updates from the
display. Changed to INVENTORY_UPDATE, consistent with all other SpoolBuddy
device management endpoints. SSH-level security is handled by the SSH key pair,
not by this HTTP permission gate.
SETTINGS_READ was incorrectly included in _APIKEY_DENIED_PERMISSIONS. The SpoolBuddy kiosk authenticates via API key and reads settings to sync the UI language — this was always permitted before the denylist was introduced. Only write operations (SETTINGS_UPDATE, SETTINGS_BACKUP, SETTINGS_RESTORE) should be restricted to fully-authenticated users.
ScaleReadingRequest rejected negative weight_grams values (ge=0.0). The SpoolBuddy scale legitimately reads negative values when the tare offset is not perfectly calibrated. Changed lower bound to -10000.0g to accept real-world uncalibrated readings. Upper bound 100000.0g unchanged. Updated schema validation test to reflect the new accepted range.
Raw ADC values from NAU7802 with default calibration_factor=1.0 can be in the millions, far exceeding the previous le=100_000.0 constraint. Remove all ge/le bounds so uncalibrated scale readings are accepted as-is.
SETTINGS_READ is intentionally not denied for API keys — the SpoolBuddy kiosk reads settings (e.g. UI language) via API key. Move it from the expected-denied set to the expected-allowed set.
… test coverage - Add SpoolmanSlotAssignment model and DB migration for (printer, ams, tray) → spool_id mapping - Slot-assignment CRUD endpoints: verify spool exists before DB commit (H2), re-raise non-404 Spoolman errors from GET (H3), return success on 404 after DELETE (H4/CR1) - DB rollbacks added to all sync batch-persist except blocks (C1/C2/C3) and unlink_spool (H6) - Slot-map load failures promoted from debug to warning level (H1, 3 sites) - Sync: add SkippedSpool tracking for no-RFID/no-hint trays (CR3), fix asymmetry in sync_all_printers so not-found errors are also reported for non-BL RFID trays - sync_ams_tray non-BL RFID path: wrap find_or_create_filament in try/except (H5) - on_ams_change: add isinstance guards for ams_unit/tray_data (CR4); catch ValueError from init_spoolman_client so SSRF-blocked URLs log a warning and return cleanly - delete_printer: explicitly delete SpoolmanSlotAssignment rows (SQLite FK cascade not enforced without PRAGMA foreign_keys; pattern matches existing maintenance cleanup) - S4: move SpoolmanSlotAssignment import to module top-level in spoolman.py and spoolman_inventory.py; remove 3 duplicate in-function imports - Remove dead variables current_tray_uuids/synced_spool_ids across sync routes (S2/CA1) - Update stale docstrings: clear_location_for_removed_spools (CA3), ON CONFLICT comment for assigned_at (CA4), assign_spoolman_slot 404 note (CA5), get_all raw dict format (CA6) - DB init: add spoolman_slot_assignment to model import list; fix active_print_spoolman and spool_usage_history DDL to use is_sqlite() branching (removes AUTOINCREMENT from PostgreSQL path) - Frontend: slot-assignment API client methods, AssignSpoolModal integration, PrintersPage AMS slot UI, i18n keys for all 8 locales - Tests T1-T9: link/unlink Spoolman error paths, slot GET stale-row cleanup, 503 propagation, sync DB write, hint forwarding, no-RFID skipped entry, non-BL RFID error guard, hint uncached path, hint ignored when RFID present, CASCADE delete
…leanup, and test coverage B1: scrub sensitive fields in settings response for API key callers B2/I8: reject NaN/Inf on scale weight fields (allow_inf_nan=False) B3: TOCTOU-safe slot DELETE (double-predicate WHERE clause) B4: legacy SpoolmanClient methods raise typed exceptions instead of returning None B5: WeakValueDictionary for _extra_locks to prevent memory leak B6: _translate_spoolman_errors hardcodes 502 for SpoolmanClientError I7/I10: update_spool_weight checks local DB before forwarding to Spoolman I9: SSRF warning broadcast throttled to 60s cooldown I11: failures list included in bulk-create 207 response body I12: _apply_price_if_set returns (dict, list[str]) with warnings I13-I15: _translate_spoolman/spoolbuddy_errors context managers at all call sites I16: spoolman_tracking wraps use_spool in typed exception handler I17: spool_weight_warning in response when 250g fallback used N1: collapse multi-paragraph docstrings to single lines in spoolman.py N2: remove stale historical comment from auth.py denylist N3: merge nfc_write_result except blocks; add _translate_spoolbuddy_errors CM N5/N11: upgrade SSRF logger.debug to logger.warning N6: add rgba field description to Spoolman schemas N7: expand MappedSpoolFields TypedDict to 31 fields; annotate return type N8: named constraints on SpoolmanSlotAssignment (uq/ck_ams_id/ck_tray_id) N9: extract filterSpoolsByQuery to inventorySearch.ts; use in AssignSpoolModal N10: SpoolmanSlotAssignmentRow interface in PrintersPage N12: logger.warning for invalid rgba in opentag3d encoder T-Gap 1-2: test_settings_api_key_scrubbing (5 tests, loop-based fixture avoids secret scanner hits) T-Gap 3: test_ssrf_guard (21 tests) T-Gap 4: test_spoolman_slot_concurrency (3 tests) T-Gap 5: test_spoolman_slot_ddl (7 tests) T-Gap 6: test_spoolman_extra_lock (4 tests) T-Gap 7: AssignSpoolModal Spoolman-mode tests (3 tests) T-Gap 8: InventoryPageDeepLink Spoolman-mode scenario (2 tests) T-Gap 9: inventorySearch utility + 11 unit tests Add .gitguardian.yaml to exclude backend/tests/ as a hard backstop.
- Broadcast tray_uuid in all three WS events (spoolbuddy_tag_matched,
spoolbuddy_unknown_tag) so the UI knows when a Bambu spool is on the
scale but not yet linked to a Spoolman entry
- Add PATCH /api/v1/spoolman/inventory/spools/{id}/tag endpoint that
writes tag_uid or tray_uuid to Spoolman extra.tag via merge_spool_extra;
tray_uuid takes precedence when both are supplied
- Track unknownTrayUuid in useSpoolBuddyState (state, reducer, event handler)
- Fix SpoolBuddyLayout tagDetected to include unknownTrayUuid so kiosk
auto-navigates to dashboard on Bambu spool placement
- Make SpoolBuddyDashboard Spoolman-aware: fetch from Spoolman when enabled,
exclude tray_uuid spools from the untagged list, route handleLinkTagToSpool
and handleQuickAddToInventory through the Spoolman API in Spoolman mode
- Add 10 backend integration tests and 9 frontend tests covering all new paths
90cd2ec to
cd3d197
Compare
…spool link After linking a tag to a Spoolman spool, the UI stayed on UnknownTagCard because Spoolman stores the tag as tray_uuid in extra.tag — tag_uid stays null so tagsEquivalent never matched. Introduce justLinkedSpool state populated from the InventorySpool returned by the PATCH endpoint, used as a fallback in the displayedSpool memo until the device re-scans and sbState.matchedSpool is populated. Also show a success toast on link. Clear justLinkedSpool when a different tag is detected or the tag-removed path fires to prevent stale spool data leaking into subsequent scans.
|
@maziggy please review only also test PGsql. Thanks |
Description
This PR integrates Spoolman more deeply into Bambuddy so users never have to leave the app to manage their filament, regardless of which inventory backend is active.
Unified Inventory UI for Spoolman mode
When Spoolman is enabled, the Bambuddy inventory page proxies all spool data through a new backend API (
/spoolman/inventory/*). Create, edit, archive, delete and bulk-create spools — all from the same table/card UI as the internal inventory. The frontend is fully inventory-backend-agnostic.AMS Slot Assignments (Spoolman)
A new
spoolman_slot_assignmentstable maps(printer, ams_id, tray_id) → spoolman_spool_idlocally, without touching Spoolman'sspool.locationfield (which remains user-managed). A full CRUD API (POST/DELETE/GET /spoolman/inventory/slot-assignments) handles assignment, unassignment, and lookup. The sync routes use the slot table as a no-RFID fallback (spoolman_spool_id_hint): if a tray has no readable RFID tag the previously assigned spool ID is used to keep weight data flowing. Trays with neither RFID nor a hint produce aSkippedSpoolentry in the sync response instead of silently returning null. When a printer is deleted, all its slot assignments are explicitly removed (SQLite does not enforce FK cascades withoutPRAGMA foreign_keys).Storage Location field
A new "Storage Location" text field on every spool (e.g. "Shelf A, Box 3"). In internal inventory mode it is stored locally. In Spoolman mode it maps bidirectionally to Spoolman's native
locationfield — reads on load, writes back on save, and can be explicitly cleared."Open in Inventory" deep-link from AMS slot hover card
The hover card shown when hovering over an AMS slot now contains an "Open in Inventory" button (for both Spoolman-linked and internally-assigned spools). Clicking navigates to
/inventory?spool=<id>, which immediately opens the edit modal for that exact spool. Falls back to a targeted single-spool fetch if the spool list is not yet cached.SpoolBuddy NFC write support for Spoolman spools (bonus — not part of the original FR)
SpoolBuddy devices can now write OpenTag3D NDEF tags for Spoolman-managed spools, not just local DB spools. The
nfc/write-tagendpoint falls back to Spoolman when a spool is not found locally and encodes the tag via a newencode_opentag3d_from_mapped()path. After a successful write,nfc/write-resultstores the tag UID back into Spoolman'sextra.tagfield usingmerge_spool_extrato preserve all other custom fields. Spoolman spools are also fully discoverable via NFC scan (nfc/tag-scanned) and weight sync (scale/update-spool-weight) uses Spoolman's ownfilament.spool_weightas core weight instead of a hardcoded 250 g fallback.Bug Fixes
SpoolBuddy kiosk buttons broken after permission hardening
The new
_APIKEY_DENIED_PERMISSIONSdenylist inadvertently blocked three operations for API-key-authenticated sessions:queue_system_command) requiredSETTINGS_UPDATE→ changed toINVENTORY_UPDATEtrigger_daemon_update) requiredSETTINGS_UPDATE→ changed toINVENTORY_UPDATEGET /settings) requiredSETTINGS_READ→SETTINGS_READremoved from the denylistScale reading 422 errors on uncalibrated devices
ScaleReadingRequest.weight_gramshad an upper bound of100 000.0. The NAU7802 24-bit ADC with defaultcalibration_factor=1.0produces raw values in the millions before calibration, causing every scale reading to be rejected with HTTP 422. Bounds removed.Sync robustness (priority review matrix)
exceptblocks andlink_spool/unlink_spoolDB writes (C1/C2/C3/H6)debugtowarninglevel (H1, 3 sites)assign_spoolman_slot: spool verified in Spoolman before the local DB row is written — prevents ghost rows pointing at non-existent spool IDs (H2)get_spoolman_slot_assignment: non-404 Spoolman errors are now re-raised instead of silently returningnull— a 503 from Spoolman surfaces as 503 to the caller (H3)unassign_spoolman_slot: catches 404 from Spoolman after a successful local delete and returns 200 — the local unassignment succeeded regardless of whether Spoolman still has the spool (H4)sync_ams_traynon-BL RFID path:find_or_create_filamentwrapped in try/except so a Spoolman timeout does not abort the whole sync cycle (H5)on_ams_change:isinstanceguards onams_unit/tray_databefore attribute access (CR4);ValueErrorfrominit_spoolman_clientcaught so an SSRF-blocked URL logs a warning and exits cleanly instead of propagating an unhandled exception (SSRF)sync_all_printers: missingelif spool_tagbranch added so not-found errors are reported for RFID-tagged trays too, not just no-RFID trays (asymmetry fix)DB DDL dialect fix
active_print_spoolmanandspool_usage_historymigration blocks previously usedINTEGER PRIMARY KEY AUTOINCREMENT(SQLite-only syntax) unconditionally. Both now branch onis_sqlite(), matching the pattern used bysmart_plug_energy_snapshots.Related Issue
Fixes #1038
Documentation
Type of Change
Changes Made
Backend
models/spoolman_slot_assignment.py— new ORM model,(printer_id, ams_id, tray_id)unique constraint, FKON DELETE CASCADEcore/database.py— migration block forspoolman_slot_assignmentstable; DDL dialect fix foractive_print_spoolmanandspool_usage_historyapi/routes/spoolman_inventory.py— full slot-assignment CRUD (assign,unassign,get,get_all); ghost-row prevention, stale-row cleanup, 503 propagationapi/routes/spoolman.py—sync_printer_ams/sync_all_printers: slot-map loading, hint forwarding,SkippedSpoolentries, DB rollbacks, asymmetry fix;link_spool/unlink_spoolDB rollbacksapi/routes/printers.py— explicitSpoolmanSlotAssignmentdelete indelete_printerapp/main.py—on_ams_change: slot-map loading + hint,isinstanceguards, SSRFValueErrorcatch, DB rollbackservices/spoolman.py—sync_ams_tray: non-BL RFIDfind_or_create_filamentguard, hint uncachedget_spoolcall; updatedclear_location_for_removed_spoolsdocstringapi/routes/_spoolman_helpers.py—_map_spoolman_spool(), SSRF guard, helper utilitiesservices/opentag3d.py—encode_opentag3d_from_mapped()for Spoolman dict-based NDEF encodingapi/routes/spoolbuddy.py— Spoolman-aware NFC scan/write/result, weight sync; permission fixes for kiosk buttonsschemas/spoolbuddy.py— unboundedweight_gramscore/auth.py—SETTINGS_READremoved from API-key denylistFrontend
api/client.ts— slot-assignment API methods (assignSpoolmanSlot,unassignSpoolmanSlot,getSpoolmanSlotAssignment,getSpoolmanSlotAssignments)components/AssignSpoolModal.tsx— Spoolman spool picker for AMS slot assignmentcomponents/LinkSpoolModal.tsx— Spoolman link flowpages/PrintersPage.tsx— AMS slot assignment UI, "Open in Inventory" deep-linken,de,fr,it,ja,pt-BR,zh-CN,zh-TW)Tests (T1–T9 + 3 bonus)
link_spool→ Spoolman 404/503 returns correct HTTP statusunlink_spool→ Spoolman 404 returns correct HTTP statusget_spoolman_slot_assignment→ stale row cleaned up on Spoolman 404spoolman_spool_id_hintwhen no RFIDfind_or_create_filamenterror →None, not raisesget_spool(hint)calledDELETE /printers/{id}removes all slot assignmentsTesting
Backend: 3586 passed, 30 pre-existing failures (camera/timelapse hardware mocks — not introduced by this PR)
Frontend: 1447/1447 passed
Bandit security scan: 0 Medium/High findings
I have tested this on my local machine
Checklist