[PR #1114] feat(inventory): unified Spoolman inventory UI + AMS slot assignments + Storage Location + NFC write support + Spoolman Filament Catalog Picker #1162

Open
opened 2026-05-06 12:35:26 +02:00 by BreizhHardware · 0 comments

📋 Pull Request Information

Original PR: https://github.com/maziggy/bambuddy/pull/1114
Author: @netscout2001
Created: 4/24/2026
Status: 🔄 Open

Base: devHead: feature/spoolman-inventory-ui


📝 Commits (10+)

  • 4257c1a feat(inventory): unified Spoolman inventory UI + Storage Location + AMS deep-link + SpoolBuddy NFC write support (#1063)
  • 1c7406d fix(spoolman): allow LAN Spoolman in SSRF guard
  • 46fcf2b fix(spoolbuddy): use INVENTORY_UPDATE permission for system command endpoint
  • b06e745 fix(spoolbuddy): use INVENTORY_UPDATE permission for daemon update endpoint
  • c0a3c63 fix(auth): allow API keys to read settings (SETTINGS_READ)
  • 05d0306 fix(spoolbuddy): allow negative scale readings for uncalibrated tare
  • 8e45425 fix(spoolbuddy): remove weight bounds from ScaleReadingRequest
  • 8c5ce21 test(auth): update RBAC denylist test to reflect SETTINGS_READ exemption
  • 111ab19 fix(spoolman): harden slot-assignment CRUD, sync robustness, and full test coverage
  • b4c00dd fix(spoolman): harden integration — typed exceptions, SSRF, TOCTOU, cleanup, and test coverage

📊 Changes

114 files changed (+24033 additions, -2192 deletions)

View changed files

📝 CHANGELOG.md (+15 -2)
backend/app/api/routes/_spoolman_helpers.py (+318 -0)
📝 backend/app/api/routes/inventory.py (+51 -16)
📝 backend/app/api/routes/printers.py (+337 -64)
📝 backend/app/api/routes/settings.py (+93 -79)
📝 backend/app/api/routes/spoolbuddy.py (+433 -88)
📝 backend/app/api/routes/spoolman.py (+413 -109)
backend/app/api/routes/spoolman_inventory.py (+1541 -0)
📝 backend/app/core/auth.py (+51 -2)
📝 backend/app/core/database.py (+146 -37)
📝 backend/app/main.py (+169 -22)
📝 backend/app/models/spool.py (+3 -1)
📝 backend/app/models/spoolbuddy_device.py (+1 -0)
backend/app/models/spoolman_k_profile.py (+34 -0)
backend/app/models/spoolman_slot_assignment.py (+35 -0)
📝 backend/app/schemas/spoolbuddy.py (+48 -32)
backend/app/schemas/spoolman.py (+30 -0)
📝 backend/app/services/opentag3d.py (+57 -23)
📝 backend/app/services/spool_tag_matcher.py (+22 -0)
📝 backend/app/services/spoolbuddy_ssh.py (+89 -22)

...and 80 more files

📄 Description

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_assignments table maps (printer, ams_id, tray_id) → spoolman_spool_id locally, without touching Spoolman's spool.location field (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 a SkippedSpool entry 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 without PRAGMA 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 location field — 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-tag endpoint falls back to Spoolman when a spool is not found locally and encodes the tag via a new encode_opentag3d_from_mapped() path. After a successful write, nfc/write-result stores the tag UID back into Spoolman's extra.tag field using merge_spool_extra to 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 own filament.spool_weight as core weight instead of a hardcoded 250 g fallback.

Bug Fixes

SpoolBuddy kiosk buttons broken after permission hardening
The new _APIKEY_DENIED_PERMISSIONS denylist inadvertently blocked three operations for API-key-authenticated sessions:

  • Reboot / Shutdown (queue_system_command) required SETTINGS_UPDATE → changed to INVENTORY_UPDATE
  • Update button (trigger_daemon_update) required SETTINGS_UPDATE → changed to INVENTORY_UPDATE
  • UI language sync (GET /settings) required SETTINGS_READSETTINGS_READ removed from the denylist

Scale reading 422 errors on uncalibrated devices
ScaleReadingRequest.weight_grams had an upper bound of 100 000.0. The NAU7802 24-bit ADC with default calibration_factor=1.0 produces raw values in the millions before calibration, causing every scale reading to be rejected with HTTP 422. Bounds removed.

Sync robustness (priority review matrix)

  • DB rollbacks added to all sync batch-persist except blocks and link_spool/unlink_spool DB writes (C1/C2/C3/H6)
  • Slot-map load failures promoted from debug to warning level (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 returning null — 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_tray non-BL RFID path: find_or_create_filament wrapped in try/except so a Spoolman timeout does not abort the whole sync cycle (H5)
  • on_ams_change: isinstance guards on ams_unit/tray_data before attribute access (CR4); ValueError from init_spoolman_client caught so an SSRF-blocked URL logs a warning and exits cleanly instead of propagating an unhandled exception (SSRF)
  • sync_all_printers: missing elif spool_tag branch 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_spoolman and spool_usage_history migration blocks previously used INTEGER PRIMARY KEY AUTOINCREMENT (SQLite-only syntax) unconditionally. Both now branch on is_sqlite(), matching the pattern used by smart_plug_energy_snapshots.

Fixes https://github.com/maziggy/bambuddy/issues/1038

Documentation

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change
  • Documentation update
  • Code refactoring
  • Performance improvement
  • Test addition or update

Changes Made

Backend

  • models/spoolman_slot_assignment.py — new ORM model, (printer_id, ams_id, tray_id) unique constraint, FK ON DELETE CASCADE
  • core/database.py — migration block for spoolman_slot_assignments table; DDL dialect fix for active_print_spoolman and spool_usage_history
  • api/routes/spoolman_inventory.py — full slot-assignment CRUD (assign, unassign, get, get_all); ghost-row prevention, stale-row cleanup, 503 propagation
  • api/routes/spoolman.pysync_printer_ams / sync_all_printers: slot-map loading, hint forwarding, SkippedSpool entries, DB rollbacks, asymmetry fix; link_spool / unlink_spool DB rollbacks
  • api/routes/printers.py — explicit SpoolmanSlotAssignment delete in delete_printer
  • app/main.pyon_ams_change: slot-map loading + hint, isinstance guards, SSRF ValueError catch, DB rollback
  • services/spoolman.pysync_ams_tray: non-BL RFID find_or_create_filament guard, hint uncached get_spool call; updated clear_location_for_removed_spools docstring
  • api/routes/_spoolman_helpers.py_map_spoolman_spool(), SSRF guard, helper utilities
  • services/opentag3d.pyencode_opentag3d_from_mapped() for Spoolman dict-based NDEF encoding
  • api/routes/spoolbuddy.py — Spoolman-aware NFC scan/write/result, weight sync; permission fixes for kiosk buttons
  • schemas/spoolbuddy.py — unbounded weight_grams
  • core/auth.pySETTINGS_READ removed from API-key denylist

Frontend

  • api/client.ts — slot-assignment API methods (assignSpoolmanSlot, unassignSpoolmanSlot, getSpoolmanSlotAssignment, getSpoolmanSlotAssignments)
  • components/AssignSpoolModal.tsx — Spoolman spool picker for AMS slot assignment
  • components/LinkSpoolModal.tsx — Spoolman link flow
  • pages/PrintersPage.tsx — AMS slot assignment UI, "Open in Inventory" deep-link
  • i18n: new keys added to all 8 locale files (en, de, fr, it, ja, pt-BR, zh-CN, zh-TW)

Tests (T1–T9 + 3 bonus)

  • T1: link_spool → Spoolman 404/503 returns correct HTTP status
  • T2: unlink_spool → Spoolman 404 returns correct HTTP status
  • T3: get_spoolman_slot_assignment → stale row cleaned up on Spoolman 404
  • T4: sync writes slot row to DB
  • T5: sync forwards spoolman_spool_id_hint when no RFID
  • T6: non-BL RFID find_or_create_filament error → None, not raises
  • T7: hint uncached path → get_spool(hint) called
  • T8: RFID takes precedence over hint → hint path never entered
  • T9: DELETE /printers/{id} removes all slot assignments
  • Bonus: H3 503 propagation, H4 unassign on already-deleted spool, CR3 no-RFID/no-hint skipped entry

Testing

  • 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

  • My code follows the project's coding style
  • I have commented my code where necessary
  • My changes generate no new warnings
  • I have tested my changes thoroughly

🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/maziggy/bambuddy/pull/1114 **Author:** [@netscout2001](https://github.com/netscout2001) **Created:** 4/24/2026 **Status:** 🔄 Open **Base:** `dev` ← **Head:** `feature/spoolman-inventory-ui` --- ### 📝 Commits (10+) - [`4257c1a`](https://github.com/maziggy/bambuddy/commit/4257c1a93973bf2aeebd418199881f6affc83a81) feat(inventory): unified Spoolman inventory UI + Storage Location + AMS deep-link + SpoolBuddy NFC write support (#1063) - [`1c7406d`](https://github.com/maziggy/bambuddy/commit/1c7406d85e9e85ddcbaea69c33e8c7afcf652ada) fix(spoolman): allow LAN Spoolman in SSRF guard - [`46fcf2b`](https://github.com/maziggy/bambuddy/commit/46fcf2b86c775f2ef49c81865170d39b894d554f) fix(spoolbuddy): use INVENTORY_UPDATE permission for system command endpoint - [`b06e745`](https://github.com/maziggy/bambuddy/commit/b06e74564782d1608a6cc58a49d81671996344f9) fix(spoolbuddy): use INVENTORY_UPDATE permission for daemon update endpoint - [`c0a3c63`](https://github.com/maziggy/bambuddy/commit/c0a3c63aacddeae9c4f0bec24738c53238151fb3) fix(auth): allow API keys to read settings (SETTINGS_READ) - [`05d0306`](https://github.com/maziggy/bambuddy/commit/05d0306226bdf66fcad8b396f732bbc47cbc543c) fix(spoolbuddy): allow negative scale readings for uncalibrated tare - [`8e45425`](https://github.com/maziggy/bambuddy/commit/8e45425303b1ddad85c0f34cb81298e414803685) fix(spoolbuddy): remove weight bounds from ScaleReadingRequest - [`8c5ce21`](https://github.com/maziggy/bambuddy/commit/8c5ce21e5a199e87a12ef4fd2227ea7328e8f6a3) test(auth): update RBAC denylist test to reflect SETTINGS_READ exemption - [`111ab19`](https://github.com/maziggy/bambuddy/commit/111ab19c307127b359a93ba013ce5b565b5bc0f4) fix(spoolman): harden slot-assignment CRUD, sync robustness, and full test coverage - [`b4c00dd`](https://github.com/maziggy/bambuddy/commit/b4c00dd406a6eee80df420c349d0d2301c261e00) fix(spoolman): harden integration — typed exceptions, SSRF, TOCTOU, cleanup, and test coverage ### 📊 Changes **114 files changed** (+24033 additions, -2192 deletions) <details> <summary>View changed files</summary> 📝 `CHANGELOG.md` (+15 -2) ➕ `backend/app/api/routes/_spoolman_helpers.py` (+318 -0) 📝 `backend/app/api/routes/inventory.py` (+51 -16) 📝 `backend/app/api/routes/printers.py` (+337 -64) 📝 `backend/app/api/routes/settings.py` (+93 -79) 📝 `backend/app/api/routes/spoolbuddy.py` (+433 -88) 📝 `backend/app/api/routes/spoolman.py` (+413 -109) ➕ `backend/app/api/routes/spoolman_inventory.py` (+1541 -0) 📝 `backend/app/core/auth.py` (+51 -2) 📝 `backend/app/core/database.py` (+146 -37) 📝 `backend/app/main.py` (+169 -22) 📝 `backend/app/models/spool.py` (+3 -1) 📝 `backend/app/models/spoolbuddy_device.py` (+1 -0) ➕ `backend/app/models/spoolman_k_profile.py` (+34 -0) ➕ `backend/app/models/spoolman_slot_assignment.py` (+35 -0) 📝 `backend/app/schemas/spoolbuddy.py` (+48 -32) ➕ `backend/app/schemas/spoolman.py` (+30 -0) 📝 `backend/app/services/opentag3d.py` (+57 -23) 📝 `backend/app/services/spool_tag_matcher.py` (+22 -0) 📝 `backend/app/services/spoolbuddy_ssh.py` (+89 -22) _...and 80 more files_ </details> ### 📄 Description ## 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_assignments` table maps `(printer, ams_id, tray_id) → spoolman_spool_id` locally, without touching Spoolman's `spool.location` field (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 a `SkippedSpool` entry 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 without `PRAGMA 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 `location` field — 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-tag` endpoint falls back to Spoolman when a spool is not found locally and encodes the tag via a new `encode_opentag3d_from_mapped()` path. After a successful write, `nfc/write-result` stores the tag UID back into Spoolman's `extra.tag` field using `merge_spool_extra` to 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 own `filament.spool_weight` as core weight instead of a hardcoded 250 g fallback. ## Bug Fixes **SpoolBuddy kiosk buttons broken after permission hardening** The new `_APIKEY_DENIED_PERMISSIONS` denylist inadvertently blocked three operations for API-key-authenticated sessions: - Reboot / Shutdown (`queue_system_command`) required `SETTINGS_UPDATE` → changed to `INVENTORY_UPDATE` - Update button (`trigger_daemon_update`) required `SETTINGS_UPDATE` → changed to `INVENTORY_UPDATE` - UI language sync (`GET /settings`) required `SETTINGS_READ` → `SETTINGS_READ` removed from the denylist **Scale reading 422 errors on uncalibrated devices** `ScaleReadingRequest.weight_grams` had an upper bound of `100 000.0`. The NAU7802 24-bit ADC with default `calibration_factor=1.0` produces raw values in the millions before calibration, causing every scale reading to be rejected with HTTP 422. Bounds removed. **Sync robustness (priority review matrix)** - DB rollbacks added to all sync batch-persist `except` blocks and `link_spool`/`unlink_spool` DB writes (C1/C2/C3/H6) - Slot-map load failures promoted from `debug` to `warning` level (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 returning `null` — 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_tray` non-BL RFID path: `find_or_create_filament` wrapped in try/except so a Spoolman timeout does not abort the whole sync cycle (H5) - `on_ams_change`: `isinstance` guards on `ams_unit`/`tray_data` before attribute access (CR4); `ValueError` from `init_spoolman_client` caught so an SSRF-blocked URL logs a warning and exits cleanly instead of propagating an unhandled exception (SSRF) - `sync_all_printers`: missing `elif spool_tag` branch 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_spoolman` and `spool_usage_history` migration blocks previously used `INTEGER PRIMARY KEY AUTOINCREMENT` (SQLite-only syntax) unconditionally. Both now branch on `is_sqlite()`, matching the pattern used by `smart_plug_energy_snapshots`. ## Related Issue Fixes https://github.com/maziggy/bambuddy/issues/1038 ## Documentation - [x] Docs PR(s) https://github.com/maziggy/bambuddy-wiki/pull/18 - [ ] No docs update required ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change - [ ] Documentation update - [x] Code refactoring - [ ] Performance improvement - [x] Test addition or update ## Changes Made **Backend** - `models/spoolman_slot_assignment.py` — new ORM model, `(printer_id, ams_id, tray_id)` unique constraint, FK `ON DELETE CASCADE` - `core/database.py` — migration block for `spoolman_slot_assignments` table; DDL dialect fix for `active_print_spoolman` and `spool_usage_history` - `api/routes/spoolman_inventory.py` — full slot-assignment CRUD (`assign`, `unassign`, `get`, `get_all`); ghost-row prevention, stale-row cleanup, 503 propagation - `api/routes/spoolman.py` — `sync_printer_ams` / `sync_all_printers`: slot-map loading, hint forwarding, `SkippedSpool` entries, DB rollbacks, asymmetry fix; `link_spool` / `unlink_spool` DB rollbacks - `api/routes/printers.py` — explicit `SpoolmanSlotAssignment` delete in `delete_printer` - `app/main.py` — `on_ams_change`: slot-map loading + hint, `isinstance` guards, SSRF `ValueError` catch, DB rollback - `services/spoolman.py` — `sync_ams_tray`: non-BL RFID `find_or_create_filament` guard, hint uncached `get_spool` call; updated `clear_location_for_removed_spools` docstring - `api/routes/_spoolman_helpers.py` — `_map_spoolman_spool()`, SSRF guard, helper utilities - `services/opentag3d.py` — `encode_opentag3d_from_mapped()` for Spoolman dict-based NDEF encoding - `api/routes/spoolbuddy.py` — Spoolman-aware NFC scan/write/result, weight sync; permission fixes for kiosk buttons - `schemas/spoolbuddy.py` — unbounded `weight_grams` - `core/auth.py` — `SETTINGS_READ` removed from API-key denylist **Frontend** - `api/client.ts` — slot-assignment API methods (`assignSpoolmanSlot`, `unassignSpoolmanSlot`, `getSpoolmanSlotAssignment`, `getSpoolmanSlotAssignments`) - `components/AssignSpoolModal.tsx` — Spoolman spool picker for AMS slot assignment - `components/LinkSpoolModal.tsx` — Spoolman link flow - `pages/PrintersPage.tsx` — AMS slot assignment UI, "Open in Inventory" deep-link - i18n: new keys added to all 8 locale files (`en`, `de`, `fr`, `it`, `ja`, `pt-BR`, `zh-CN`, `zh-TW`) **Tests (T1–T9 + 3 bonus)** - T1: `link_spool` → Spoolman 404/503 returns correct HTTP status - T2: `unlink_spool` → Spoolman 404 returns correct HTTP status - T3: `get_spoolman_slot_assignment` → stale row cleaned up on Spoolman 404 - T4: sync writes slot row to DB - T5: sync forwards `spoolman_spool_id_hint` when no RFID - T6: non-BL RFID `find_or_create_filament` error → `None`, not raises - T7: hint uncached path → `get_spool(hint)` called - T8: RFID takes precedence over hint → hint path never entered - T9: `DELETE /printers/{id}` removes all slot assignments - Bonus: H3 503 propagation, H4 unassign on already-deleted spool, CR3 no-RFID/no-hint skipped entry ## Testing - 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 - [x] I have tested this on my local machine ## Checklist - [x] My code follows the project's coding style - [x] I have commented my code where necessary - [x] My changes generate no new warnings - [x] I have tested my changes thoroughly --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/bambuddy#1162
No description provided.