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.
Spoolman holds per-spool pricing, and #261 gave that as the reason for
integrating with it. Nothing ever read it. A print's cost is set once, at
archive time, from the built-in Filament catalogue matched on the primary type
and falling back to a global default rate -- and in Spoolman mode nothing
revisited that figure afterwards. The per-spool recompute that would have fixed
it, in usage_tracker.on_print_complete, runs only over rows the built-in
inventory writes, and Spoolman mode hands the usage tracker spoolman_owns_usage
at print start so it writes none. The reporter's catalogue was empty, which is
the ordinary state of one in Spoolman mode, so every print came out at the
default no matter what the linked spool cost.
Multi-material was wrong twice over there: the primary type's rate applied to
the whole print's weight, so a slot of expensive PA was billed at the price of
the PLA beside it.
Each slot is now priced from the spool it was actually charged to, at the
moment of the charge, and the per-slot costs are summed -- which is what fixes
the multi-material case, rather than a separate change. All three charge paths
feed it: per-slot, tray-split, and the remain%-delta fallback. The rate is the
spool's own price when set, else the filament's, over filament.weight. That is
net grams excluding the core, and the same field the remain-delta path already
divides by to turn a percentage into weight, so a spool that can be charged by
percentage can always be priced. The price comes out of the get_spool call the
colour and material rewrites already pay for, so the tagged path costs no extra
round trip.
Grams no spool could price are covered at the global default in one subtraction
against the archive's own total. A spool with no price, a tray with no Spoolman
row, and filament the sliced file never attributed are the same case from here,
and without the top-up a print with one priced slot out of four would report a
quarter of its cost -- #1344 in the other inventory mode. Only the first run
writes the archive, matching the built-in writer (#1378); reprint actuals live
in PrintLogEntry. If no slot could be priced at all, whatever archive.py
recorded is left alone, so an install with prices in neither place stays where
it was.
Applied even when the slot-to-tray mapping was a positional guess, unlike the
colour and material rewrites beside it. Those overwrite what the slicer
recorded, which is why a guess must not touch them. The cost has no such
original -- archive.py's figure is itself derived from a default rate -- and the
grams have already been deducted from these spools, so the archive should say
what that deduction was worth.
Both cost recalculations would have undone it on the next run. /rescan and
/recalculate-costs rebuild an archive's cost from SpoolUsageHistory and fall
back to the catalogue or the default when there are no rows, which in Spoolman
mode is always, so the fallback was not a recalculation but a downgrade. The
spool-to-slot resolution a price is derived from exists only while a print is
completing and cannot be rebuilt from the archive row, so both now leave a cost
alone rather than replacing it with a worse one, and the bulk endpoint reports
how many it kept. An archive with no cost yet is still priced, and with
Spoolman off both behave exactly as before.
The rate parser refuses more than it looks like it needs to, because everything
it refuses was reachable. A non-dict filament raised through a call that sits
after a successful use_spool, which would have abandoned the remaining slots of
a multi-material print with the charges already made. NaN compares False
against every bound, including the applier's own total <= 0, so a NaN price
would have been written to the archive with nothing downstream able to clear
it; two finite operands can produce it by overflow, so the quotient is checked
as well as the inputs. A bool is an int in Python, and float(True) is 1.0 -- a
weight of 1 g prices a spool per-gram at its whole cost. And a spool-level price
of 0 now falls through to the catalogue rather than reading as free: Spoolman
leaves the override null when unset, but importers write 0 often enough that
treating it literally would price a whole print at the default with a good
catalogue price one level down.
A sliced file numbers its filaments 1..4; which AMS tray each came from is
decided when the job is sent. #2768 gave the Spoolman writer two ways to
recover that decision when the print did not come through Bambuddy: the
printer's own mapping field, and a colour match of the 3MF's slots against the
loaded trays. An A1 satisfies neither. It publishes no mapping field, and it
drops the MQTT connection when we subscribe to its request topic, so the
slicer's instruction never arrives either. That leaves the colour match, and it
compares hex strings exactly.
The reporter sliced with a generic black profile against a tray they had set to
charged slot 1 to whatever sat in the first tray -- 2.17 g onto a grey PLA+
spool, while the print was fed from tray 3. Their bundle carries the printer's
own answer: "Tray change during print: tray=3 at layer=0", recorded 90 seconds
in, and read further down the same completion pass by _print_used_tray_keys to
decide which slots the print had touched. The same pass then charged tray 0 on
a guess, and logged "AMS0-T3: remain% did not fall over the print" about the
tray that had actually done the work.
_single_slot_tray_from_state adds the third rung. For a print with exactly one
slot carrying usage, the one slot came from the one tray, so the printer's tray
reporting answers the question directly: the mid-print tray-change log, then
the tray loaded at print start, then the current one, then the last real tray
seen. That is the ladder usage_tracker.on_print_complete has consulted since it
started resolving mappings at completion -- Spoolman users were the only ones
not getting it, which is why an install running the built-in inventory has
never shown this. On this printer only the first and last rungs can fire:
tray_now_at_start is 255 because print start runs before the filament is
loaded, and the A1 parks tray_now back at 255 the moment a print ends.
Gated on exactly one slot with usage, like the internal writer: a multi-colour
print moves tray_now on every change, so one reading cannot then be attributed
to one slot. It also declines when the log holds more than one switch, because
an AMS-backup runout is split per segment (#1793) and a single-tray mapping
would land the whole print on one spool.
That gate needed the guess warning to exclude the split path too. The split
never reads slot_to_tray at all -- it charges each segment to the tray the
printer announced switching to, which is the same evidence this fallback is
built on -- so a declined mapping there is not a guess, and calling it one
suppressed the archive rewrite for exactly the prints whose attribution is best
supported. Nothing covered that combination; a test does now.
Where nothing names a tray the positional default still stands, because it is
right for an AMS loaded in slicer order. It now says so at warning level so a
support bundle carries the reason, and it no longer restamps the archive's
filament colour and material from a spool it picked by position. That restamp
is what made the fault read as data loss: the grams can be put back, whereas
overwriting what the slicer recorded leaves nothing to compare against, and the
reporter's archive had already been rewritten from #000000 to the wrong spool's
grey. A slot that consumed nothing also stops claiming a tray in the handled
set -- it was never charged, so the remain-delta path should stay free to cover
it rather than be suppressed by an estimate of zero.
The request-topic probe is the same failure reached from the other side. A
printer that refuses kills the TCP connection instead of returning a SUBACK
failure, so the only signal is "we subscribed, then got disconnected", and that
was believed the first time it happened. Every other reason a connection drops
inside the same window looks identical -- a network blip, the printer
rebooting, the container stopped mid-probe -- and the verdict was cached per
serial with no re-probe anywhere, so on a printer that supports the topic one
unlucky drop cost mapping capture for the rest of the process and every slicer
print after it was charged by tray position. It now takes two consecutive
drops, and a disconnect we asked for is not counted. A printer that genuinely
refuses answers the same way every time and pays one extra reconnect; one
already known to refuse still skips the subscription outright rather than
reopening a reconnect loop.
Two faults behind the same kind of print: one that arrives without a
retrievable 3MF, which on an H2S is any job started from the printer's
own internal library.
Such an archive has no file_path, and Path("").parent is Path("."), so
every site that derived the archive's folder from it landed on the data
directory itself. The finish-photo capture spotted that and wrote to
<archive_dir>/<id>/photos instead. Nothing else did. The photo was
written in one place and looked for in another: reads 404'd, deletes
dropped the name and left the file, and the notification attachment
never found the image. Hand-uploaded photos worked only because upload
and read agreed with each other rather than with the capture. Give the
question one owner in utils/archive_paths and have all four sites ask
it. Lookups check the old shared location too, so photos already
uploaded there stay reachable; uploads now go where captures go.
Separately, the remain%-delta fallback that stands in for a missing 3MF
can charge nothing for several reasons, and did so without a word. The
AMS reading is coarse and, on the reporter's printer, noisy: it rises
mid-print, swings five points over a job, sits at 100% through a
36-minute print on a fresh spool, and goes negative on a nearly empty
one -- which the start-of-print gate rejects, dropping the only slot
that was printing. Two of their prints went uncounted for two different
reasons and both read as "no spools updated", which is also what a print
with nothing to charge prints. Name the slot and the two readings in
each case, on the Spoolman path and on the internal-inventory path,
which has carried the same gates since #1119.
The Spoolman path also had no notion of which slots the print used, so a
spool swapped into an idle slot mid-print reads as consumption and is
billed to whoever that slot is assigned to -- the fault #1269 fixed for
the internal tracker, still open here, and likeliest on exactly the
prints this fallback serves, where nothing else narrows the field. Use
the same three pieces of evidence it does: the print's mapping, its
mid-print tray changes, and the tray it started on. The last needs
storing, because the internal tracker's row is deleted before this runs
and a screen-started print has no mapping to fall back on -- hence a new
nullable column, and no backfill, since a row from before it existed has
nothing to say. Where no evidence exists at all, every slot is still
considered.
Both paths also treated tray_now == 255 as naming a slot. It does not:
it is the field's initial value, the fallback for an unparseable
reading, and what it reports with nothing loaded. Mapped as a tray id it
becomes (255, 1), so as the only evidence it excluded every real slot
and charged nothing at all -- this issue's own bug, arriving by a new
route. On the internal path that is live today; on the Spoolman path it
would have shipped with the guard above. The external holder reports 254
when it is genuinely in use.
The arithmetic is untouched: at one percent per step this cannot resolve
a small print, and pretending otherwise would be worse than saying so.
Everything the completion path needs to split a print's filament across
the trays it fed from lived only in memory: the dispatched plate and
slot-to-tray mapping, the spool-assignment snapshot, and the tray-change
log. A print that outlived a restart lost all of it and fell back to
what the printer reports at completion -- which, with AMS Filament
Backup on, is the substitute tray. The whole print was charged to the
spool that only finished it while the spool that ran dry was charged
nothing.
Persist that context in a new active_print_sessions row, append tray
changes as they happen, and restore both the session and the printer's
tray-change log at restart recovery. Seed the log from the current tray
when there is nothing to restore, since last_loaded_tray advances even
when no change is logged.
Rank the queue item's stored ams_mapping above the printer's live
mapping field, which is what backup rewrites. Recover plate_id from the
archive or queue item, and give extract_layer_filament_usage_from_3mf a
plate_id instead of taking the first .gcode member -- a Bambu Studio
export stores plate 2 first, so per-layer figures were measured against
the wrong plate for both inventory backends.
Stop auto-unlinking a spool assignment when its slot reports empty
during a running print. At a runout the spool is still in the AMS, and
dropping the link leaves the completion path nothing to charge.
Capture the print-start context for both inventory backends. Spoolman's
own durable row (#1820) carries its plate-scoped figures and dispatched
mapping but not the tray-change log, and its slot assignments -- the
way. Registration in _active_sessions stays gated, since on_ams_change
reads it to decide whether to skip the remain%-based weight sync (#880).
A sliced file numbers its filaments 1..4; which AMS tray each came from is
a separate decision made when the job is sent. store_print_data learns it
from one of two sources, both of which require the print command to pass
through us: the mapping Bambuddy chose itself, or the one it intercepted
on the printer's local request topic. A job dispatched from Bambu Studio
while the printer is cloud-bound satisfies neither -- the command travels
through Bambu's broker and never reaches the topic we subscribe to.
slot_to_tray is then NULL and _resolve_global_tray_id guesses by position:
filament 1 from the first loaded tray, filament 2 from the second. The
reporter's X1C was loaded in the order 2, 4, 1, AMS-HT, so all four slots
were charged to the wrong spool. Their log carries the printer's own
answer, mapping=[1, 3, 0, 32768], sitting unread.
usage_tracker has consulted that field since it started resolving mappings
at completion, along with a colour match against the loaded trays for the
models that never publish it (A1, A1 Mini, P1S, P2S). Only the Spoolman
writer, which resolves at print start, never learned to -- and main.py
gates usage_tracker behind Spoolman being off, so enabling Spoolman is
what costs you the better resolver.
_resolve_slot_to_tray_fallback gives it both, at completion rather than at
print start: a printer keeps publishing the last job's mapping while it
sits idle, so reading it early would risk stamping the previous print's
mapping onto this one. A mapping we or the slicer actually recorded is
never second-guessed.
Applied in _report_partial_usage too. Cancelled and failed prints feed the
same slot_to_tray to the same resolver and mis-charged just as readily.
The resolved mapping and its source are now logged at print start and at
completion. "source: none" at start is the signal that completion will
have to fall back, and it was the one line that would have turned this
report into a five-minute triage.
Not addressed: editing the mapping after the fact, which the reporter also
asked for. ArchiveUpdate exposes neither filament field and there is no way
to re-run an attribution, so that is a feature rather than a fix.
A slot mapped to a different filament than it was sliced for (PLA slice
routed to the only loaded PETG slot) was logged under the sliced material
in the archive, Print Log and material stats, even though the correct spool
was debited. Once usage tracking resolves every used slot to a spool, adopt
the spool's material as the archive filament_type, exactly as the spool
colour is already adopted (#1494). All-or-nothing; both inventory backends;
flows through to the Print Log and stats. No schema/UI/i18n change.
usage_tracker's tray-switch split has never had a Spoolman peer.
An AMS same-material runout switch mid-print charged the whole slot
to the origin spool via the (via tag) path and double-credited the
backup via remain-delta — origin exceeded initial_weight.
Extract the segment-math into utils/tray_split.compute_tray_split_grams
and call it from both writers so the two inventory backends attribute
mid-print switches identically. spoolman_tracking gains
_report_spool_usage_split_by_tray_changes; the Path 2 remain-delta
fallback now skips trays the split path covered, killing the
double-count.
Brings the Spoolman writer up to parity with the internal-inventory
side, which has had this fallback since #1119. When a Bambu print
starts without a retrievable .gcode.3mf on the printer (typically an
unsaved BambuStudio project, subtask_name='Untitled'), Spoolman no
longer silently skips the print's filament consumption.
- ActivePrintSpoolman.filament_usage now nullable; new tray_remain_start
column captures per-slot {remain, tray_uuid} at print start.
- store_print_data: always snapshots remain, even when 3MF is present
(mirrors usage_tracker.on_print_start), so partial-3MF prints can also
fall back per-slot.
- report_usage: 3MF path stays primary; new _report_remain_delta_for_slots
handles slots the 3MF didn't cover via delta * Filament.weight / 100,
resolving the spool via the existing slot-assignment table.
- _report_partial_usage: same fallback for aborted no-3MF prints.
#1119 invariant preserved: per-slot, per-print, gated on a valid
start/current remain AND a resolvable Spoolman spool. Uses curated
Filament.weight (not MQTT's unreliable tray_weight) — same trick the
internal-inventory side uses.
Mid-print spool swap detected via tray_uuid mismatch → slot skipped.
Double-charge prevented via handled_global_tray_ids dedup.
When a print targets a single plate from a multi-plate 3MF, both the
internal Filament Inventory tracker and the Spoolman-mode tracker parsed
the 3MF without a plate filter and summed every plate's filament — so a
single lid print debited the spool the entire file's grey + black totals.
The 3MF parser already supports plate_id (queue pre-flight uses it at
print_queue.py:254/:286). Plumbed it through both dispatch paths:
Queue path:
- PrintSession gains a plate_id field; on_print_start queries the
printer's currently-printing queue row and records queue_item.plate_id
onto the session.
- _track_from_3mf accepts plate_id and passes it to the extractor.
- store_print_data moves its existing queue-item lookup above the
extract and uses queue_item.plate_id as the plate filter.
Direct-Print path (reprintArchive / printLibraryFile — never goes
through the queue):
- _print_plate_ids dict added in main.py, parallel to _print_ams_mappings.
- register_expected_print accepts plate_id and stores it; the 2 sites in
background_dispatch.py and the 1 site in print_scheduler.py now pass
it (resolve was already happening, just needed reordering before the
register call so the value is available).
- Expected-print promotion in main.py injects _print_plate_ids[archive_id]
into the session, guarded so a queue capture wins over the dict.
- _get_start_plate_id helper feeds plate_id into all 3
_store_spoolman_print_data call sites; spoolman_tracking.store_print_data
takes the caller value first, falls back to queue_item.plate_id.
PrintArchive.filament_used_grams stays file-level summed by design
(#1593's contract — the archive describes the file, not the run); only
the per-run usage attribution becomes plate-aware. Single-plate direct
prints resolve to plate_id=1 → plate 1 = whole file, identical to the
prior no-filter behaviour.
Two attacker-controlled strings were being joined to library_dir with no
resolve + containment check in the project ZIP import endpoint:
- linked_folders[*].name from the request's project.json
- per-entry zf.namelist() paths from the ZIP itself
An absolute path in either field collapsed the join (Path("/lib") / "/etc"
becomes Path("/etc") because pathlib discards the left side when the right
is absolute) and the next write_bytes landed wherever the attacker chose.
Adjacent finding from the routes audit: GET /archives/{id}/photos/{filename}
had NO validation on filename and FileResponse-served arbitrary paths -
the DELETE counterpart at least gated on the photos membership check.
Adjacent finding from the services audit: ArchiveService.attach_timelapse
wrote archive_dir / filename where filename ultimately came from a printer's
FTP listing (compromised-printer threat model) or the /timelapse/select
query param. A malicious printer that exposes a directory entry with ..
segments could write the timelapse outside the archive directory.
New backend/app/utils/safe_path.py::safe_join_under(parent, *parts) is the
single source of truth: rejects empty / null-byte / absolute parts up-front,
joins under parent, resolves both sides, asserts is_relative_to. Returns the
resolved canonical path on success, raises HTTPException(400) on escape, or
PathTraversalError when http=False (for service-layer callers that need to
match a non-HTTP return contract).
Wired into the import vectors, both archive photo handlers, and the
attach_timelapse service. The full audit sweep inspected every Path/Name
join in backend/app/api/routes/ AND backend/app/services/ - 25 route-layer
sites + 8 service-layer sites confirmed safe and tagged with
# SEC-PATH-OK: <reason> so future audits trust the inline guard at a glance.
Fifth CI backstop test_route_path_arithmetic_is_safe_joined_or_marked
AST-walks both layers and fails the build on any <dir-like>/<bare variable>
join that doesn't either route through safe_join_under or carry the marker.
The services layer is in scope because it receives values verbatim from the
routes AND from external sources Bambuddy has no control over (the printer
FTP-listing case above).
SECURITY.md gets a fifth rule + a fifth row in the CI test mapping table;
the rule now names the printer FTP-listing case explicitly so future
services-layer audits set the right expectation.
--------------
fix(library): suppress warning storm when bulk-uploading ZIPs of empty/stub STL files
Uploading a ZIP of stub or empty STL files (e.g. the 24-byte
"solid test\nendsolid test" shape) produced one WARNING per file in
stl_thumbnail.py::generate_stl_thumbnail. The warnings were technically
correct - trimesh returns a valid Mesh with zero vertices, the safeguard
matches, and the function returns None so the library entry is still
created without a thumbnail - but the volume turned a successful upload
into thousands of WARNING lines in the journal.
Two changes:
1. The per-file "Failed to load STL or empty mesh" message in
stl_thumbnail.py is now logger.debug instead of logger.warning. It's
a per-file content observation, not an actionable error; the caller
already handles None correctly. The branch now catches the rare
"large enough but trimesh still can't parse it" case, visible in
debug logs without spamming production.
2. New module constant MIN_USABLE_STL_BYTES = 200 (smallest binary STL
with one triangle is 134B, smallest ASCII ~150B; 200 is a safe floor
below any real STL). The three thumbnail call sites in library.py
(extract_zip_file, single-file upload, _backfill_external_stl_thumbnails)
pre-skip files below this size before calling generate_stl_thumbnail.
Stubs never enter the trimesh pipeline at all.
Behavior is unchanged for real STLs: any file >=200 bytes runs through
the existing pipeline, MAX_VERTICES still triggers simplification at
100k vertices for the 256x256 thumbnail render, large files still get
thumbnails.
------------
fix(stl-thumbnail): silence matplotlib first-import noise (writable cache + font_manager log level)
On first STL upload, three matplotlib-internal log lines surfaced:
WARNING [matplotlib] /opt/claude/.config/matplotlib is not a writable directory
INFO [matplotlib.font_manager] Failed to extract font properties from NotoColorEmoji.ttf
INFO [matplotlib.font_manager] generated new fontManager
The writable-dir warning fired because Bambuddy's $HOME isn't writable for
matplotlib's default config path; matplotlib fell back to /tmp/matplotlib-XXX
which lost the font cache on every host reboot, so font_manager rebuilt it
each cold start - producing another batch of INFO lines.
Fix is two small additions in stl_thumbnail.py before the matplotlib import:
1. New _configure_matplotlib_cache() sets MPLCONFIGDIR to
settings.base_dir/.cache/matplotlib (mkdir if missing) so the cache
persists across container restarts and the writable-dir warning never
fires. Respects an externally-set MPLCONFIGDIR so operators who chose
their own path aren't overridden. Best-effort with a debug fallback if
settings can't be imported or the mkdir fails.
2. logging.getLogger("matplotlib.font_manager").setLevel(WARNING) at module
import demotes the per-font INFO scan that fires when font_manager
builds its cache cold. Real font warnings (>= WARNING) still surface.
3 new tests: font_manager logger at WARNING after module import;
_configure_matplotlib_cache creates the directory under base_dir and sets
MPLCONFIGDIR; an externally-set MPLCONFIGDIR is preserved verbatim.
5516 backend tests green, frontend gates clean.
An archive's filament_color was parsed verbatim from the print job's
3MF (filament_colour in project_settings.config) — the slicer's
filament-slot colour, which a user picks independently of the exact
hex they curate on the Bambuddy inventory spool. So a print from a
#000000 inventory spool showed #161616 (the slicer's near-black) in
the archive card and the Color Distribution graph, even though usage
tracking correctly decremented the right spool.
Once usage tracking has resolved the print's filament slots to
inventory spools, the spool colours are authoritative. _track_from_3mf
(built-in inventory) and report_usage (Spoolman mode) now overwrite
the archive's filament_color with the slot-ordered, de-duplicated
colours of the matched spools.
The rewrite is all-or-nothing: it only applies when every used slot
resolved to a spool carrying a colour, so a partially-mapped
multi-colour print keeps the 3MF colour rather than silently dropping
the unmatched slots.
Shipped for both inventory modes: built-in spools read Spool.rgba,
Spoolman spools read the spool's filament.color_hex (fetched via
get_spool for tag-less slot-assignment matches). New helpers
_spool_color_to_hex / _archive_colors_from_spools in usage_tracker.py,
reused by spoolman_tracking.py via _apply_spool_colors_to_archive.
Tests: 12 new in test_usage_tracker.py (hex normalisation, the
all-or-nothing rule across single/multi/partial/no-colour/AMS-fallback
cases, end-to-end rewrite), 4 in test_spoolman_tracking.py (Spoolman
rewrite + empty/partial/missing-archive no-ops). 70 tracking tests
green; backend ruff clean.
Reporter on Postgres + Spoolman saw weight never decremented after
prints. Traced to _report_spool_usage_for_slots calling only
client.find_spool_by_tag() — which returns None when extra.tag is empty.
Non-RFID spools assigned via the Bambuddy UI intentionally leave
extra.tag empty (per #1457 — we don't want fallback tags polluting
Spoolman), so tag-less spools never got matched and weight tracking
silently no-op'd. The tracker never consulted the local
spoolman_slot_assignments table that has the binding.
Adds _resolve_spool_id_via_slot_assignment() as stage 2 of the
resolution chain. Stage 1 (existing tag-lookup) wins when present so
RFID auto-sync remains unchanged. (ams_id, tray_id) derived from
global_tray_id via the existing _global_tray_id_to_ams_slot helper,
so external slots and AMS-HT slots resolve correctly. Threaded
printer_id through the three callers (partial G-code, partial linear,
final-usage report). Resolution path is logged ("via tag" vs "via
slot-assignment") so support bundles confirm the fix is live.
extra.tag is deliberately NOT auto-populated — that would re-introduce
the exact pollution #1457 cleaned up. Slot-assignment table is the
source of truth for non-RFID; extra.tag is reserved for hardware RFID.
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.
BambuStudio encodes virtual tray IDs (254/255) as -1 in the flat
ams_mapping array — a convention already documented in
bambu_mqtt.py:start_print(). The spoolman tracking helper was treating
-1 as "unmapped, use position-based default", which mapped slot_id=1
to AMS tray 0 and credited external-spool prints to whatever Spoolman
spool happened to be linked to AMS slot 0. The reporter's TPU prints
on an H2S were credited to a PLA spool for ~49g over 4 prints before
being noticed (regression of #853).
When slot_to_tray[slot_id-1] == -1 and ams_trays contains 254/255,
return the external tray ID directly. Prefers 254 over 255 (matches
single-nozzle tray_now reporting + the vir_slot id=255->254 remap in
bambu_mqtt.py:864). Legacy fall-through preserved for callers that
don't pass ams_trays.
Root cause investigation and patch by @ojimpo.
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.
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.
Backend:
- Add dual external spool support for H2D (vt_tray as list: Ext-L/Ext-R)
- Add cloud filament ID map endpoint (/cloud/filament-id-map)
- Fix RFID spool data erased by periodic AMS updates (skip tag matcher
for RFID-tagged trays)
- Fix AMS slot config overwrites RFID spool state
- Fix K-profile selection corrupts existing profiles on X1C/P1S
- Resolve K-profiles filament name via cloud filament ID map
- Update print scheduler and usage tracker for dual external spools
Frontend:
- Add printer model filtering to ConfigureAmsSlotModal (cloud/local/builtin
presets filtered by @BBL model suffix and compatible_printers)
- Add pre-population for configured slots (preset, color, K-profile)
- Add K-Profiles view with accurate filament name resolution
- Internationalize all ConfigureAmsSlotModal strings (en/de/fr/it/ja — 21 keys)
- Add 5 new ConfigureAmsSlotModal tests (model filtering, pre-selection,
color pre-population, i18n)
- Update PrintersPage for dual external spool rendering
Docs:
- Update CHANGELOG, README, website features, and wiki AMS docs
AMS-HT global tray ID was calculated as ams_id * 4 (= 512 for unit 128)
but AMS-HT uses the raw ams_id directly since it has a single tray.
The backend misidentified 512 as an external spool, producing wrong
ams_mapping2. Fixed in 4 locations: frontend getGlobalTrayId(), backend
start_print() ams_mapping2 builder, print scheduler, and Spoolman tracking.