A group can now be limited to a set of printers. Its members see and
control only those printers. Every other printer answers 404, as if it
didn't exist.
- Groups gain restrict_printers and a group_printers table (migration
for SQLite and Postgres). A user's printers are the union of their
limited groups. Groups without the flag don't limit anything, a user
in no limited group keeps every printer, and admins see all of them.
- core/printer_scope.py holds the scope. RequestPrinterScope and
RequirePrinterPermissionIfAuthEnabled apply it to routes: printer
routes, camera, queue and batches, archives, projects, stats, print
log, pipeline runs, inventory and Spoolman assignments, maintenance,
smart plugs, scheduled drying, firmware and Obico status.
- API keys, camera stream, Cam Wall, overlay and WebSocket tokens carry
the printers of whoever created them. WebSocket broadcasts are
filtered per connection, and the filtering fails closed.
- Scheduler: "Any <model>" jobs stay on their owner's printers. A job
pinned to a printer its owner lost waits with a reason. Callers with
no user identity and limited printers must queue to a specific printer.
- Group editor: new Printer access section, translated into all 15
locales. Saving a system group no longer resends unchanged
permissions, which the backend refused.
Spoolman keeps a full spool's net weight on the spool (initial_weight)
and falls back to the filament's weight, but Bambuddy read only the
filament, so a 250 g spool of a 1000 g filament showed and synced as
1000 g. The list, weigh, AMS sync, SpoolBuddy scale, remain-% tracking,
fill bar and cost now share one lookup. Create writes initial_weight,
and a label-weight edit writes it instead of patching or duplicating the
filament. The spool form's cost per kg is converted at the spool's size
to and from Spoolman's per-spool price.
A printer files each calibration under a nozzle id of the form HH00-0.4
(high flow) or HS00-0.4 (standard) and can hold both for one diameter -- a
maintainer's H2D carries 102 high-flow entries against 6 standard --
because the same filament reads a different K through each. Nothing read
that, so a standard-flow profile could be selected for a high-flow nozzle
and vice versa.
The flow is now stored with the profile, shown against each option in the
picker, and checked before a stored profile is applied. Two spellings have
to agree for that: a calibration entry says HH00-0.4 while the fitted
nozzle reports HH01, so the comparison is two characters rather than four
-- the trailing digits are a hardware variant the calibration table
normalises to 00.
Unknown flow on either side matches anything, which is what it has to do.
Every profile stored before this has none. And an X1C declares none on any
profile at all -- probed live, all eight come back with an empty nozzle id,
against a four-digit cali_idx and a populated setting_id -- even though the
machine really does take either nozzle. supports_nozzle_flow_type is
therefore the wrong thing to gate on: it returns True for an X1C, and
treating that silence as Standard would have dropped every X1C profile the
moment a high-flow nozzle was fitted. What the printer's own table declares
per profile is the test.
NozzleInfo.nozzle_type carries two vocabularies by printer generation --
the nozzle material on legacy printers, the flow code on H2 -- and the
comment claiming only the former is corrected. Anything that is not HH or
HS reads as unknown, which is what makes the material spelling harmless.
Storing both flows for one hotend and diameter is deliberately not done:
spoolman_k_profile is UNIQUE on (spool, printer, extruder, diameter) with
no flow column, and allowing a second row in internal mode alone would
break inventory-mode parity. The picker marks a profile whose flow does not
match what is fitted instead of letting it look configured while doing
nothing.
A slicer preset is bound to a printer model: "Bambu PLA Basic @BBL X1C" is
not the same preset as "@BBL H2C", and Bambu names a nozzle size in it as
well. A spool carried exactly one, which was right until the same spool was
used on a second machine -- the AMS slot on the other one was then
configured with a preset that machine has no profile for. K profiles had
the matching gap from the other side: the tables have always been keyed per
hotend, but the picker could not express it.
spool_filament_preset and its Spoolman twin store the exceptions, keyed
(spool, printer_model, nozzle_diameter). Model rather than printer because
the preset is a property of the model -- "@BBL X1C" is the same preset on
every X1C, and asking per machine would mean picking the identical value
twice. K profiles stay on printer_id, because a K value is measured on one
physical hotend and two machines of the same model legitimately differ.
Resolution is exact (model, diameter) -> (model, "") -> the spool's own
preset, so a spool nobody has configured behaves exactly as it did before.
The form writes one row per nozzle size and never the "" row; that level is
kept for API clients wanting one value to cover a model.
Both halves cover every standard nozzle size rather than the size currently
fitted, because a spool is configured once and nozzles get swapped. The PA
Profile tab becomes a Printers tab: a model list beside a detail pane
holding a preset row per size and a K-profile grid of size by hotend. Each
model is offered only the presets that name it, through the same matcher
the Configure AMS Slot modal filters with, which moves out of that
component into utils/slicerPrinterMatch. Presets whose name identifies no
model -- most user-authored and OrcaSlicer ones -- stay offered everywhere,
as does whatever is already selected, so a saved override cannot vanish
from the control that shows it. Every preset carries an origin badge in the
wording and colours that modal already uses.
Every path that configures a slot now respects both: manual assign in
either inventory mode, RFID auto-assign, the Spoolman tag link, the re-fire
when a slot goes empty to loaded, the re-apply after a calibration-table
refresh, and the re-selection when a Filament Track Switch moves an AMS to
the other nozzle. Which nozzle a slot feeds, and how wide it is, was worked
out independently in seven of those places, each reading nozzles[0] for
every slot on the machine -- correct on a single-nozzle printer and on a
dual-nozzle printer with matching nozzles, wrong the moment two sizes are
fitted. That resolution is now services/slot_nozzle.
Which array entry belongs to which hotend is no longer inferred. Measured
on an H2D fitted with a 0.4 high flow on the left and a 0.6 on the right,
nozzles[0] reads the right hotend, so the array is indexed by extruder id
and the H2/X2 parser's convention is the one that holds. The legacy
parser's opposite convention never governs a real dual-nozzle machine:
every model in DUAL_NOZZLE_MODELS reports device.nozzle.info, and
left_nozzle_diameter appears in no log or wire capture. Two comments that
said otherwise were wrong and are fixed; amsHelpers' code was right all
along and only its comment lied.
Four defects surfaced while wiring it, all pre-existing except the last.
The picker identified a chosen calibration by cali_idx alone, and the
printer numbers its calibration table per nozzle -- on a dual-nozzle
machine the same index exists on both hotends meaning different things, so
saving could persist the other hotend's K value and diameter; SpoolBuddy's
write-tag page carried a verbatim copy and gets the same fix. RFID
auto-assign chose a K profile with no extruder test at all, so a spool
calibrated on both hotends had a coin toss decide which pressure-advance
value the slot got, on the path that runs unattended every time a Bambu
spool is loaded. The Spoolman tag-link path resolved no preset whatsoever,
configuring every linked slot with a generic material id and discarding a
preset set in inventory -- the same defect #1713 fixed on the assign path,
one function over. And an FTS inlet move re-selected K for nozzle 0 rather
than for the nozzle the AMS had just been moved to.
The last one is new here: a per-model override can be a cloud USER preset,
whose PFUS-prefixed id the slicer rejects, and passing it straight into
extrusion_cali_sel would silently lose the K-profile link. Reached the
printer only where such an override exists, which is why nothing in the
suite caught it. printer_safe_filament_id falls through to the spool's own
preset and then the tray's RFID value instead.
Reading a printer's calibration table asks for one nozzle size at a time.
H2-series firmware answers only the first one or two of a concurrent burst
of extrusion_cali_get and silently drops the rest, each dropped request
costing a five-second timeout before its retry: measured at 11 and 23
seconds on an H2C and an H2D for four parallel requests, against roughly
one second in series. An X1C answers all four at once, which is why this
only ever surfaced on dual-diameter printers. Printers themselves are read
in parallel -- separate machines are separate connections.
The Configure AMS Slot dialog opens on the spool's own configured values,
falling back to the slot's last manual configuration and then the tray's
RFID data. The spool form is wider for the two-pane layout, colour, weight,
cost and location move to their own tab in two columns, and a printer card
in expanded view lists every fitted nozzle size rather than the first entry
alone.
The reduction that gave an AMS slot a material type read PLA-AERO,
PLA-GF, ASA-GF and PPS-GF as their base material, so a slot loaded with
foaming or glass-filled filament went out saying plain PLA or ASA. That
is worse than the bug it replaced. "PLA-AERO" matched nothing before,
which was useless but honest; "PLA" matches every PLA plate in the
queue, so the dispatcher would have sent one to filament that will not
print it -- and the contract the first fix claimed, that it could only
ever repair a slot, no longer held. @doncaruana caught PLA Aero on the
issue.
All four are values Bambuddy itself offers: filament_fields.json is the
material list the Profiles editor puts in a dropdown, and the reduction
table was assembled from the cloud filament names and the frontend
preset parser without ever being checked against it. It is checked now,
so the next type added to one and not the other fails a test rather than
a print. ASA-AERO joins them from the cloud catalogue (GFB02).
The table hyphenates because the slicers do, while a spool says "PLA
Aero" and every Bambu preset name says "Bambu PLA Aero". Adjacent words
are joined and taken when the join is a type exactly -- exactly, because
letting the prefix and suffix rules reach across a space would make
"Support for PLA" a type by its tail.
Also from @doncaruana, and the better half of his point: a preset is
chosen from a list the slicer defines, so it already knows its own type
and nothing has to be read out of a product name. The resolver now hands
that answer back and both assign routes prefer it. It cannot be the only
source -- material is required on a spool and slicer_filament is not, and
the spool this issue was reported for had no preset at all -- so the
reduction stays as the fallback for spools without one.
Two things had to move with it. The auto-unlink guard compared the
slot's reported type against the reduced material, so a spool whose
preset outranked its material column would have been unlinked from the
slot it had just been assigned to; it now accepts any type the assign
path could have written. And two lookups keyed by material took the
catch-all for a type they had no row for, which sent an ASA-GF spool out
at 200/240 -- too cold to extrude -- and preheated its chamber to
nothing. Both fall back to the base material last, so PLA-CF, PETG-CF
and PA-CF keep the rows they are listed with, and ASA-CF and ABS-GF pick
up ranges they had been missing all along.
What counts as a material name is decided by the base for the same
reason: saying yes throws the value away and rescues the slot from the
generic-material fallback, so the answer has to be no when that fallback
has nothing to offer. ABS-GF reduces to a generic ABS the printer can
resolve; PPS-CF reduces to nothing and is left as it stands. Adding a
type to the table therefore cannot quietly change that answer, which is
how these five slipped through in the first place.
------
Hand the bundled chamber-preheat table back the way it is read
Every lookup of the per-filament chamber map happens after the keys are
upper-cased, and the parser documents exactly that: keys uppercased,
DEFAULT always present so the resolution loop can index it
unconditionally. The three fallback paths returned the bundled constant
as declared, with the lowercase "default" row the Settings editor writes
and displays, so an install that had never opened the setting got a dict
the loop could not read its fallback out of and used a hardcoded 0 for
any filament without a row of its own.
It reported the right number only because that bundled default is 0.
Raising it would have changed nothing for everyone who had not
customised the map, with the map in Settings still showing the value
that was not being used.
The test that should have caught this asserted the fallback under either
spelling, and its docstring contradicted itself between title and
comment. It pins the contract now, over all four ways the parser can
fall back.
Spoolman rejects a spool whose extra dict carries a key it has not been
told about, answering 400 "Unknown extra field tag.". Bambuddy keeps the
tray UUID in extra.tag, so that key has to exist before the first spool
is created. Registration ran from three hand-maintained lists that fire
when the integration is set up -- the connect route, startup, and two
inline blocks in the inventory routes. Enabling Spoolman from the
Settings page reaches none of them, so the first AMS sync on a fresh
Spoolman failed on every slot while vendor and filament creation
succeeded.
Neither fix suggested on the issue is quite the right shape. Adding the
block to PUT /settings/spoolman fixes this path and makes a third copy
of a list that has already drifted -- it would still omit
bambu_color_name. Ensuring at the sync entry point leaves the other four
tag writers alone: linking and unlinking a tag, and both inventory edit
paths.
So the registration moved to the write. create_spool, update_spool and
update_spool_full each register the keys of the extra dict they are
about to send, once per client, before sending. Every tag writer funnels
through one of the three, merge_spool_extra included. This closes the
class rather than the instance: a write that carries a key is a write
that registers it, and bambu_color_name shows why that matters -- it
never made it into the connect or startup lists at all, and works today
only because two call sites remembered it by hand.
Best-effort, deliberately. ensure_extra_field already logs and returns
False rather than raising, so a registration that fails leaves the write
to be attempted and to report exactly what it reported before. Failures
are not memoised either, so a Spoolman that was merely restarting gets
another try on the next write.
The older blocks stay. They are redundant now, but the inventory routes'
inline calls are pinned by tests that assert them against a mocked
client, where the funnel cannot run.
The status endpoint is the other half. The Connect button would have
registered the fields, and the reason nobody reaches it is that
GET /spoolman/status reported "connected" whenever an earlier request
had left a client object behind. Roughly twenty call sites build one
lazily, and saving the Settings page builds one as a side effect of
syncing locations, so the flag turned on which page had been opened
rather than on anything about Spoolman. The UI reads it twice -- Connect
only while disconnected, the sync section only while connected -- so
those two controls landed in states the user cannot explain. It now asks
the Spoolman that is configured, including the stale-URL check every
other route already does, and does not probe at all when the integration
is switched off, which used to let a leftover client report a disabled
Spoolman as connected.
That leaves nothing for Disconnect to do, so it is gone. Spoolman is a
stateless HTTP API with no session to close; the button dropped the
client object, the next request rebuilt it lazily, and the status
flipped back on its own within the 30s poll -- an action that looked
like it worked and then quietly undid itself. The enable toggle owns
turning the integration off. Connect stays as what it always was in
practice, a way to re-check a Spoolman that is not answering, and is
shown only then. Its translation keys are left in place; only the
control is removed.
Resolving the client there means the status poll can now fail in ways a
read-only check could not, so it no longer reports failure by failing.
Replacing a client closes the previous one and httpx's aclose() is not
guaranteed not to raise; a poll that runs every 30 seconds answering 500
is worse than one answering what is true either way, which is that
Spoolman could not be reached. The SSRF rejection keeps its own message
rather than folding into the general one -- a URL the guard refuses is
the admin's to correct, and that is only actionable if the log says so.
Tests drive the real client against a fake Spoolman that enforces the
unknown-extra-field rule rather than mocking it away, so each one fails
against the old code for the reason the reporter's install did. The
concurrency test needed the fake to be async: MockTransport answers
without suspending, so the first version ran each request to completion
in turn and passed just as happily with the lock removed.
Assigning a spool wrote its material straight into the slot's tray_type.
A slot that says "PLA+" satisfies nothing that asks for PLA: not
OrcaSlicer, not Bambu Studio, and not Bambuddy's own dispatch matcher,
which compares the type the printer reports to the one the 3MF declares
as plain equality. The reporter's slot was unusable for every PLA plate
he had.
Not only a label, either. The same string went into the generic-filament
lookup, which missed, so the slot went out with no tray_info_idx at all
-- the half-configured state #2604 documents the printer as reverting
from -- and took the 200/240 catch-all nozzle range instead of PLA's
190/230.
PLA+ is not a special case. Bambuddy's own colour catalogue supplies the
material dropdown, and around forty of its values are vendor product
lines rather than filament types: HTPLA, PolyTerra PLA, PLA Matte, ASA
Extrafill, Flexfill TPU 98A.
So the four routes that configure a slot reduce the material to a name
the printer knows before sending it, and the product name moves to
tray_sub_brands -- which is where Bambu Lab puts it too: their catalogue
carries a preset named "eSUN PLA+" whose type is PLA. A name the
reduction cannot place is sent exactly as before rather than guessed at,
so this can only repair a slot, never break a working one. The spool's
own wording still leads the id and temperature lookups with the reduced
type appended behind it, so "PETG HF" keeps its own generic preset
(GFG96) rather than being traded down to plain PETG's.
Two guards decide whether a candidate filament id is really a material
name -- the resolver's, which discards one, and slot reuse, which will
not carry one forward. Both saw only bare types, so "PLA+" passed as a
filament id. They share one answer now, which also refuses to read an
id-shaped value: "GFPLA" ends in a material name, and reducing it would
throw away the calibrated preset in the slot.
One thing had to move with it. on_ams_change auto-unlinks an assignment
whose slot stopped looking the way it did when the spool was assigned,
and the check that spares a slot Bambuddy itself reconfigured compared
the printer's reported type against the spool's raw material. With the
slot now carrying the reduced type, every spool this issue is about
would have been unlinked from the slot it had just been assigned to.
Both sides are reduced there -- the printer's too, so slots configured
by an older version, still reporting "PLA+", keep matching.
Something starts working as a result: a slot holding a calibrated preset
is reused when a same-material spool is assigned to it, which could not
happen for these spools while "PLA" and "PLA+" compared unequal.
Reverting any one of the behaviours above fails a distinct test -- the
reduction's four matching rules and its pass-through contract included,
since that contract is what makes the rest of it safe.
The 7cb905a follow-up mounted the global unknown-tag modal listener, which
turned an existing always-on broadcast for no-tag slots from a silent no-op
into a perpetual popup loop — every push for a slot with a generic
non-RFID spool (or zero-filled tag) re-prompted, and confirming each one
created a fresh ghost spool with an empty tag.
- main.py on_ams_change: drop the no-tag else-branch broadcast. No identity,
no prompt; the slot stays unassigned until a real tag is read.
- inventory.py + spoolman.py /spools/from-slot: 400 when the slot has no
usable tag_uid / tray_uuid so stale frontends can't recreate the ghost
spool by re-confirming a queued prompt.
- test_inventory_from_slot_no_tag: lock the guard in (zero-filled + empty
string).
New setting "Auto-add unknown RFID spools" under Settings -> Filament -> Filament Tracking,
default ON for back-compat. When turned off, the backend stops auto-creating an inventory
record for an unknown RFID tag and instead broadcasts an unknown_tag WS event that pops
a global confirmation modal in the Bambuddy UI showing the printer / AMS-X label / slot /
material / colour. Add or Cancel; no nag on every MQTT push.
Backend
- Module-level _unknown_tag_last_broadcast dict dedupes per (printer, slot, tag). Set is
committed AFTER ws_manager.broadcast() returns so a crashed broadcast doesn't poison
the dedup and permanently silence the slot.
- Empty-slot MQTT push clears that slot's entry, so remove+reinsert reliably re-prompts.
- Successful matches via get_spool_by_tag / find_matching_untagged_spool / create_spool
also clear the entry so a future tag swap re-prompts.
- Tray data (tray_type, tray_color, tray_sub_brands, tray_count) shipped in the WS payload
directly so the modal renders the real material / colour instead of relying on the
React Query cache that lags the WS event by several seconds.
- Two new endpoints back the modal's confirm action:
POST /api/v1/inventory/spools/from-slot (INVENTORY_UPDATE)
POST /api/v1/spoolman/spools/from-slot (FILAMENTS_UPDATE)
Both look up the slot's tray data server-side and create + auto-assign atomically.
- Spoolman /from-slot now raises HTTP 500 when the slot-assignment INSERT fails instead
of returning success while the DB rolled back the binding.
- sync_ams_tray gained an optional auto_add_unknown_rfid kwarg (default True so existing
callers are unaffected); auto-sync and both manual sync routes thread the setting.
Frontend
- useUnknownTagPrompt hook listens for the unknown-tag CustomEvent, reads the tray fields
out of the event detail, and feeds a single-modal queue. No long-lived dismissed set;
the backend dedup handles spam suppression.
- UnknownSpoolModal wraps the existing ConfirmModal with a material + colour-swatch
preview block.
- Mounted in Layout.tsx alongside useSponsorPrompt so SpoolBuddy kiosk / login / setup
routes are excluded.
- getAmsLabel moved to utils/amsHelpers.ts; ConfigureAmsSlotModal.tsx and PrintersPage.tsx
both import the shared version (canonical AMS-A / HT-A / External labels).
- AppSettings TS interface gained spoolman_enabled, auto_add_unknown_rfid, spoolman_url
so the runtime cast in the hook is no longer needed.
- SpoolmanSettings.tsx gets a new toggle row in the Filament Tracking card, visible in
both built-in and Spoolman branches; auto-save + toast already wired.
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.
Reporter on a P1S with non-RFID spools saw an old, almost-empty spool in
the AMS hover card's "Spulen-ID" block while the "Zugewiesen" block
correctly showed the freshly assigned full spool. Two layers compounded:
(1) Non-RFID slots fall back to a deterministic per-slot tag
(hash(serial) + ams_id + tray_id). The Link / Assign routes wrote
that tag to Spoolman extra.tag but never cleared it from the
previous holder on re-binding.
(2) The frontend's hover-card resolver preferred the (stale) tag-link
over the user's explicit slot-assignment. Same precedence bug in
SpoolBuddy's fill-bar resolver and slot-action picker.
Frontend: swap precedence at 5 sites — slot-assignment outranks tag-link
everywhere. FilamentHoverCard's existing match-dedupe then collapses the
two "Open in Inventory" buttons back into one.
Backend: new _clear_stale_tag_links() in spoolman_inventory.py, called
from POST /spoolman/inventory/slot-assignments (with the slot's
deterministic fallback tag) and POST /spoolman/spools/{id}/link (with
the literal tag being bound — works for RFID and fallback). Best-effort:
Spoolman 5xx and per-spool patch failures log + continue, never wedge
the bind. get_fallback_spool_tag_for_slot promoted to a public helper
mirroring the frontend's signature exactly.
Spoolman had two mutually-exclusive weight paths gated on the
`disable_weight_sync` flag. The default (False) used AMS remain%
x tray_weight auto-sync, which silently dropped non-BL spools
because the AMS doesn't report tray_weight without RFID. The
inventory_remaining fallback would have covered it, but the
spool_assignment table it reads from is wiped on Spoolman
activation, so non-BL spools got no weight updates at all.
Match the internal Filament Inventory: per-print tracking always
runs, AMS auto-sync no longer writes remaining_weight (it still
maintains spool metadata and slot assignments). The setting
becomes a no-op; left in the schema and UI for backwards compat.
- store_print_data: drop the disable_weight_sync early return
- sync_ams_tray callsites in main.py + routes/spoolman.py: force
disable_weight_sync=True so weight is never written by AMS sync
- new regression test confirming tracking runs with flag=false
feat(spoolman-inventory): squashed feature work for rebase onto dev
Squashed all commits from feature/spoolman-inventory-ui onto a single commit
to enable a clean rebase onto dev. Original per-commit history preserved at
backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721.
feat(spoolman-inventory): squashed feature work for rebase onto dev
Squashed all commits from feature/spoolman-inventory-ui onto a single commit
to enable a clean rebase onto dev. Original per-commit history preserved at
backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721.
* Add filament_vendor field to UnlinkedSpool model and populate from API response
* Add filament_vendor field to UnlinkedSpool interface
* Enhance LinkSpoolModal to include filament_vendor in search and display
The on_ams_change auto-sync callback set locations for new spools but
never called clear_location_for_removed_spools(), leaving stale locations
that caused double-booked slots. Also pass synced_spool_ids in the
single-printer sync route to match the sync-all endpoint behavior.
* Enhance link spool functionality with additional printer and AMS details
* Refactor linkSpool function to accept detailed context object for improved spooling integration
* Enhance LinkSpoolModal to include amsName in props and linkSpool mutation for improved functionality
* Add amsName prop to PrinterCard for enhanced spool linking functionality
* Updates LinkSpoolModal test to work with recent spool location update changes
* Adds clear_location to unlink testing
* Refactor LinkSpoolRequest to remove ams_name and update location generation logic for improved clarity
* Remove amsName from LinkSpoolModal and PrinterCard for cleaner API integration
* Remove amsName from LinkSpoolModal test props and update linkSpool mock response for improved clarity
* Expanded link logic to accept a generic spool tag (spool_tag, tray_uuid, or tag_uid) and validate 16/32-char hex values, including rejection of all-zero tags. Added a new unlink endpoint that clears Spoolman extra.tag for a given spool ID.
* Updated the link validation expectation to match the new 16-or-32-hex rule. Added an integration test for the new unlink endpoint to verify extra.tag is cleared and success is returned.
* Updated the link API call to send spool_tag instead of tray_uuid. Added a new unlinkSpool API helper that calls the new backend unlink route.
* Switched modal data source from inventory spools to Spoolman unlinked spools so the list matches the backend response you shared. Updated the link action to call Spoolman link directly and use trayUuid or tagUid as the slot tag.
* Refactored Spoolman card behavior so actions render correctly across linked/unlinked cases, and added support for an Unlink from Spoolman button under Open in Spoolman. Final UI rule now hides Unlink for Bambu Lab filament and shows it only for non-Bambu linked spools.
* Added fallback slot-tag generation so untagged slots can still be linked/unlinked reliably and linked spool lookups work consistently across AMS, AMS-HT, and external trays. Wired a new unlink mutation and passed per-slot unlink callbacks into the hover card, with query invalidation for linked/unlinked spool caches after unlink.
* Added/updated the noTrayUuid message to reflect that either tray UUID or tag UID is required when no slot tag is available. This keeps error text aligned with the new tag fallback and modal behavior.
* Added noTrayUuid message in the other languages. The translation was done using Google Translate.
* Updated LinkSpoolModal tests to use Spoolman API instead of inventory API, with new UnlinkedSpool data structure and Select Spool UI text.
* Adds hashSerialToHex32, uses new function in getFallbackSpoolTag and updates all uses. This ensures we're generating a fallback spool tag using the printers serial number rather than the database ID for consistency.
* Removes 'import json' from unlink_spool and moves to the top of the file.
* Removes hasUnlinkedSpools and related references since it's no longer used
* Removes noTrayUuid from translations and references in components.
* Adds confirmation dialog before fully unlinking spool from Spoolman
* Adds unlinkConfrimTitle and unlinkConfirmMessage. All languages other than English were done using Google Translate
* Fixes unlink toast to use dedicated unlinkSuccess and unlinkFailed keys.
* Changes tag priority on spoolTag, fixes type error by adding non-null assertion to spoolTag
* Switches around tag_uid and tray_uuid on const trayTag
---------
Co-authored-by: MartinNYHC <mz@v8w.de>
AMS Lite units (A1 series) have no weight sensor and always report 0%
fill level. When a spool is linked to Spoolman with weight data, use
Spoolman's remaining weight as a fallback. External spools also show
fill level from Spoolman data instead of always showing unknown.
Backend: Enrich GET /spoolman/spools/linked response with
remaining_weight and filament_weight alongside spool ID.
Frontend: Add getSpoolmanFillLevel() helper. Update regular AMS, HT
AMS, and external spool fill computations to use Spoolman fallback
when AMS reports 0%. Show "(Spoolman)" indicator in hover card when
fill data comes from Spoolman.
Implements retry mechanism to handle intermittent network errors when
fetching spools cache for AMS sync operations.
Changes:
- Add retry logic to get_spools() with 3 attempts and 500ms delay
- Configure httpx client with connection pool limits to prevent stale
connection reuse (max_keepalive_connections=5, keepalive_expiry=30s)
- Recreate client on connection errors (ReadError, RemoteProtocolError)
- Abort sync operations if cache fetch fails after all retries
- Update on_ams_change, sync_single_printer, and sync_all_printers to
handle cache fetch failures gracefully
This addresses ReadError(ClosedResourceError()) failures that occurred
intermittently when Spoolman closed idle connections or connection
pooling reused stale connections.
Testing:
- Added 4 new unit tests for retry behavior
- All 1018 tests passing
- Add cached_spools parameter to find_spool_by_tag, find_spools_by_location_prefix, sync_ams_tray, and clear_location_for_removed_spools
- Fetch spools once before loops in on_ams_change, sync_single_printer, and sync_all_printers endpoints
- Cache newly created spools during sync to avoid duplicate API calls
- Add 5 unit tests for caching functionality (all passing)
- Reduce redundant API calls when syncing multiple AMS trays
- Improve sync performance for users with large spool databases
- Maintain backward compatibility with optional cached_spools parameters
Implement accurate per-filament usage tracking for Spoolman integration,
similar to OpenSpoolman v0.3.0. This replaces the previous single-spool
reporting with multi-material aware tracking.
Features:
- Parse G-code from 3MF files at print start to build per-layer,
per-filament cumulative extrusion maps
- Store tracking data in new `active_print_spoolman` database table
(survives server restarts for long prints)
- Report accurate partial usage when prints fail/cancel based on
actual layer progress and G-code data
- Add "Disable AMS Weight Sync" setting to prevent AMS percentage-based
weight estimates from overwriting Spoolman's granular tracking
- Add "Report Partial Usage for Failed Prints" toggle (only shown when
weight sync is disabled)
- Use Spoolman's filament density instead of defaults for mm-to-grams
conversion
- Prefer tray_uuid over tag_uid for spool identification
When a spool is already linked in Spoolman, the FilamentHoverCard now shows
"Open in Spoolman" button instead of "Link to Spoolman". This allows users
to quickly navigate to the spool's page in Spoolman for editing.
Changes:
- Add GET /api/v1/spoolman/spools/linked endpoint returning tag->spool_id map
- FilamentHoverCard shows "Open in Spoolman" when linkedSpoolId is set
- "Link to Spoolman" only shows when spool is not linked
- Fix unlinked spools detection to strip JSON quotes from empty tags
- Add toast notifications for link success/failure
- Invalidate linked-spools query after linking
- Add backend tests for linked spools endpoint
- Add frontend tests for LinkSpoolModal
Closes#210
The 'tag' extra field is required in Spoolman for storing RFID/UUID
identifiers that link Bambu Lab spools to Spoolman entries. Previously,
users had to manually create this field in Spoolman, causing sync
failures for fresh installations.
Added ensure_tag_extra_field() method to SpoolmanClient that:
- Checks if the 'tag' field exists via GET /api/v1/field/spool/tag
- Creates it via POST if missing
The method is called automatically:
- On app startup when auto-connecting to Spoolman
- When user clicks "Connect" in Spoolman settings
The spool detection only checked tray_uuid and tag_uid, but some Bambu Lab
spools have RFID read issues causing these to be missing while still having
valid tray_info_idx (Bambu filament preset ID like "GFA00").
Backend changes (backend/app/services/spoolman.py):
- Add tray_info_idx to AMSTray dataclass
- Parse tray_info_idx from raw MQTT data in parse_ams_tray()
- Check tray_info_idx first in is_bambu_lab_spool() - most reliable indicator
- Bambu Lab presets start with "GF" (GFA00, GFB00, GFL00, etc.)
- Fall back to tray_uuid then tag_uid for unique spool identification
- Add warning when Bambu spool detected but no unique ID for Spoolman
Backend changes (backend/app/api/routes/spoolman.py):
- Update /sync/{printer_id} and /sync-all to pass tray_info_idx
- Guard against empty spool_tag when adding to tracking set
Frontend changes (frontend/src/pages/PrintersPage.tsx):
- Add isBambuLabSpool() helper with same detection logic as backend
- Update all 4 vendor detection locations in AMS cards to use new helper
Detection priority (same on frontend and backend):
1. tray_info_idx starts with "GF" (Bambu preset ID)
2. tray_uuid is valid 32-char hex (non-zero)
3. tag_uid is valid 16-char hex (non-zero)
The spool detection logic only checked tray_uuid (32 hex chars) to identify
Bambu Lab spools. Some spools have RFID read issues causing missing or
invalid tray_uuid while still having valid tag_uid (16 hex chars).
Changes:
- Update is_bambu_lab_spool() to accept tag_uid as fallback parameter
- Prefer tray_uuid for identification, fall back to tag_uid if invalid
- Use whichever valid identifier for Spoolman tag storage and matching
- Update both /sync/{printer_id} and /sync-all endpoints
This fixes spools being incorrectly marked as "Non-Bambu Lab spool (no RFID
tag)" when they have valid tag_uid but missing/zero tray_uuid.
Previously, when syncing to Spoolman, the location field was set to the
printer/AMS slot but was never cleared when the spool was removed from
the AMS.
Now during sync, the system tracks which tray UUIDs are currently in the
AMS and clears the location for any spools that were previously at this
printer but are no longer present.
Changes:
- Add clear_location parameter to update_spool() method
- Add find_spools_by_location_prefix() to find spools at a printer
- Add clear_location_for_removed_spools() to clear stale locations
- Update sync_printer_ams and sync_all_printers endpoints to clear
locations for removed spools after syncing
- Link to Spoolman: Manually link existing Spoolman spools to AMS trays
- Hover over AMS slot to see "Link to Spoolman" button
- Select from list of unlinked Spoolman spools
- Automatically sets extra.tag field in Spoolman
- Spool UUID display: View and copy Bambu Lab spool UUID in AMS hover card
- Shows first 8 chars with copy button (full UUID to clipboard)
- Fallback copy method for HTTP contexts
- Sync feedback: Shows synced count and skipped spools with reasons
- Expandable list when more than 5 skipped
- Color swatches for identification
- Fixed Spoolman 400 Bad Request: extra field values must be valid JSON
- Updated documentation (wiki, website)