Two printers went on printing while every archive they produced held nothing
but a filename. Bambuddy opened port 990, the printer accepted the connection
and answered with something that was not TLS, and connect() logged a warning
and returned False -- indistinguishable, to every caller, from "the file is
not at this path". So the 3MF lookup walked all six filename variants across
five directories with four retries each, the cover endpoint ran its own
sixteen-path sweep, and the timelapse scan added four more, all against a
sixteen-path sweep, and the timelapse scan added four more, all against a
printer that could not have answered any of them. One reporter's log carried
1813 identical handshake failures, another's 3511.
The evidence says this is the printer's own file service getting stuck, not a
model, firmware or TLS-configuration problem. In #2780's bundle the same two
printers ran clean from 22 July to 4 August and failed again from the 5th; a
second bundle shows an X2D serving files for five days, flipping on 19 July,
then failing every connection for eight days with zero successes. The same
models and firmware appear in roughly twenty other bundles with no occurrences
at all. Both bundles show it happening with cap_tls_v1_2 in effect -- the X2D
and H2C entries in ftp_profiles were added on analogy with P2S to fix exactly
this symptom, and the reporter's own debug line proves they do not.
An ssl.SSLError from connect() now opens a five-minute cool-off for that
printer. Subsequent connects return False without touching the network, so a
wedged printer is contacted twice an hour instead of hundreds of times a
minute, and the single warning that is logged names the remedy. The cool-off
is dropped on expiry rather than kept, so the map holds one key per currently
wedged printer. ftps_handshake_blocked() lets the sweeps stop: the 3MF lookup
abandons the remaining paths and skips the directory-walk fallback, the cover
endpoint returns 503 naming the file service instead of a 404 that reads as
"this print has no thumbnail", and the timelapse scan separates 503 (cannot
reach the printer) from 404 (no timelapse directory) -- one 500 used to cover
both, which is what the reporter hit when reproducing.
The Connection Diagnostic completed a bare TCP connect to 990, which is why it
reported the port green throughout: the port is open, it is what is behind it
that is broken. It now completes a real implicit-TLS handshake using the
model's own ftp_profiles cap, so a pass means the FTP client would also get
through. An open port that cannot negotiate reports warn with reason no_tls,
selecting a new message in all 13 locales that points at a printer restart
rather than at the firewall. No login is attempted, so this stays valid in the
pre-save Add Printer flow.
The cool-off tests run against a real socket that accepts on 990 and replies
with a plaintext FTP banner, reproducing WRONG_VERSION_NUMBER rather than
mocking ssl. The autouse fixture clearing _mode_cache now clears the cool-off
map too -- every test here talks to 127.0.0.1, so one left behind would make
the next test's connect() a no-op.
PrintQueueItem.created_by_id is what the queue:read_own / queue:update_own /
queue:delete_own permissions filter on, but only three of the paths that create
queue items were setting it.
The Library's bulk "Add to queue" required Permission.QUEUE_CREATE and then
bound the dependency to `_`, discarding the user, so every item it created was
ownerless -- and invisible to the person who added it if their permissions are
scoped to their own work. That is the one path built for adding many files at
once, which is where it was hardest to notice.
The webhook queue endpoint has no request user, but APIKey.user_id records the
key's owner, which is the acting identity everywhere else the key is used, so
its items are credited to that owner. Keys minted before per-user ownership
have no user_id and their items stay ownerless.
The virtual-printer path is left as-is on purpose. VirtualPrinter carries no
owner, and the obvious substitute is wrong rather than incomplete: one admin
typically configures the VP while everyone sends prints through it, so
crediting those to the admin would make the "added by" column lie and put other
people's jobs in the admin's own queue. Existing NULL rows are not backfilled
-- there is no record of who created them, and the ownerless case is already
handled throughout.
Tests pin both fixed paths and the two cases that must stay ownerless (auth
disabled, legacy key).
Projects were flat. The parent_id column and the sub-project list existed
but nothing could set a parent outside the API, and a master project's
stats only ever covered its own prints.
The project dialog gets a parent picker, and a project with sub-projects
gets a second card covering the whole tree -- jobs, parts, time, filament,
cost, and progress against every target in the tree added together. That
card is separate from the project's own stats, which keep their existing
meaning; widening them would have restated the figures of anyone who had
already nested projects over the API. Each listed sub-project carries its
own branch's roll-up, so the rows add up to the card above them.
On the Projects page a sub-project is drawn inside its parent's group
rather than as another card in the grid -- two cards columns apart cannot
show that they belong together, whatever the caption says. A sub-project
whose parent the status filter has hidden stays put and names its parent
instead.
compute_project_stats now goes through the same grouped aggregation as the
roll-up rather than its own copy of the SQL, since the two must agree.
Three things the interface made reachable:
- PATCH refused only a project as its own direct parent, so A -> B -> A
was two calls away. A cycle has no root to roll up to, and the walk
keeps its seen-set for databases that already contain one.
- A sub-project's percentage was completed quantities against the plate
target, disagreeing with the page it linked to.
- Deleting a mid-tree project orphaned its children at top level; they
now move up to its own parent.
settings was gated on settings:update because a restore rewrites rows
PUT /api/v1/settings/ owns. The same argument applies to the other three
categories, and gating one but not the rest is the only state that is
not defensible: a role holding Backup alone could still write spools,
archives and K-profiles through a restore that it cannot write through
the endpoints that own them.
Each category now also requires that endpoint's write permission -
inventory:update, archives:update_all and kprofiles:update. archives
takes update_all rather than create because a restore writes rows owned
by other users, which is exactly what update_all means.
All missing permissions are reported in one refusal: a restore is a
multi-select, so naming them one at a time turns picking four categories
into four round trips.
Binds binary_sensor and reading-carrying sensor entities to a printer and
renders their state on its card, worded by Home Assistant's device_class.
Optional per-sensor alert condition drives a notification on the transition
into the alert state and an opt-in interlock that holds queued prints while
alerting -- a hold with a readable waiting_reason, never a failure, and only
ever on a sensor that was read successfully.
Sensors get their own table rather than a wider entity pattern on SmartPlug:
get_smart_plug_by_printer would otherwise hand the card's power button a door
contact to switch.
The hold is passed to the model matcher directly rather than merged into
busy_printers: _check_auto_drying reads that set as "is currently printing"
and would put an idle-but-held printer down the mid-print drying path.
The notification_providers migration spells its default FALSE, not 0 --
Postgres rejects an integer default for a boolean and _safe_execute swallows
the error.
The follow-up to the same report: a second AMS 2 Pro, no aux power,
loaded entirely with PLA and drying at the 45C the reporter picked,
showed 45C and then switched to 55C.
Bambu never echoes back a cycle's filament or temperature, so both come
from the target cached when the command went out, and the fallback for
a missing cache reads the loaded trays. The first pass narrowed that
fallback to units whose spools agree on a filament, which fixed the
mixed-unit case in the original report but left the uniform case
answering with the spools' RFID-recommended drying_temp -- 55C here.
Agreement across slots is evidence of what is being dried, because the
dryer heats all of them. It is no evidence of the temperature, which is
picked freely in the popover, so the recommendation was never more than
a guess wearing the same confident "PLA @ 55C" as a known target.
uniform_tray_drying_hint therefore becomes uniform_tray_filament_hint
and returns the filament alone. The badge names a temperature only when
we sent it, and otherwise shows the filament and the countdown.
Both status builders also stopped filling the two fields independently.
Entering the fallback when either was missing let a cached filament pair
with a guessed temperature and render as though both were known; the
temperature now simply has no fallback to reach.
The badge required both fields before rendering anything, so dropping
the temperature would have blanked it rather than shortening it -- the
frontend now renders each on its own terms. No new translation key: the
filament type is a passthrough.
This changes what is shown when the cached target is missing, not why
it goes missing. If the reporter was on the fixed build, the falling-
edge gate is still letting a zero through on an unpowered unit, which
needs a log covering the start of the cycle.
The restore endpoint was gated on `github:restore` alone, and the settings
category rewrites arbitrary non-auth `Settings` rows. Backup and Settings are
separate permission groups, so a role holding only Backup could change
settings it cannot change through `PUT /api/v1/settings/`, the endpoint that
owns them.
The inconsistency is ours rather than an inference: this module already makes
exactly this argument — it is why the four protected auth keys are refused
outright — and `library.py` sets the precedent of elevating a route to
`settings:update` for the same reason.
Gated per-category rather than by demoting `github:restore` wholesale, so it
stays narrow and doesn't presume the answer for `spools:*`, `archives:*` and
`kprofiles`. That broader permission-model question goes to the maintainer in
the PR reply.
`current_user is None` only means auth is disabled — `github:restore` is in
`_APIKEY_DENIED_PERMISSIONS`, so an API key never reaches the route body.
No frontend change: `request()` puts the 403's `detail` on the Error, and the
modal already renders `restoreMutation.error.message` in its red block, so
the user sees the missing permission named.
Tests: 1 regression (a Backup-only role gets 403 and `run_restore` is never
awaited) + 3 controls (the same role still restores the other three
categories; a role holding both permissions still restores settings; auth
disabled is unaffected). The regression confirmed failing against the pre-fix
route. `_create_config` gained an optional token so it works under auth.
A settings restore refuses to write anything credential-shaped, but wrote the
switches that depend on those credentials like any other key. Restoring the two
halves apart is not a partial restore, it is a downgrade.
The sharp case is Prometheus. /api/v1/metrics is on PUBLIC_API_ROUTES and its
only gate is `if token:`, so an empty or absent token means no authentication at
all. prometheus_token matches the `token` hint and is refused; prometheus_enabled
is an ordinary key and was written. On an instance that never enabled Prometheus
there is no local token row, so overwrite-*off* alone was enough to publish the
whole metrics body to anyone who could reach the port. The new integration test
shows exactly that: 200 with a full unauthenticated body before, 404 after.
Four more pairs are the same shape and break an integration rather than open one:
ldap_enabled/ldap_bind_password, mqtt_enabled/mqtt_password, ha_enabled/ha_token
(with an HA_TOKEN env arm, since get_homeassistant_settings prefers the
environment over the row), and virtual_printer_enabled/virtual_printer_access_code
— the last largely vestigial post-migration, included for consistency.
A toggle is refused only when all five hold: the payload value is truthy, the
backup carried a non-empty companion credential, that credential is denylisted,
this instance has no usable value for it, and the toggle is not already on
locally. The second condition is what keeps the rule honest — an anonymous MQTT
broker and an anonymous LDAP bind are legitimate configs that pass empty
credentials straight through, and without it both would be false positives. With
it, the rule fires only when the restore would produce a config weaker than both
the backup and the local instance. A present-but-blank prometheus_token row
counts as unusable, since that is precisely the `if token:` hole.
The rule needs the payload *and* local database state, which the old static
_count_items could not see, so preview and restore now share one classifier:
_plan_settings() runs a single SELECT over both halves of every candidate pair
before anything enters the session, and returns the three refusal buckets.
preview() takes the session the route already has. _is_skipped_setting_key is
gone rather than having its docstring corrected as asked: a name is no longer
enough to decide, so the union predicate had no caller left.
Also implements the review's third ruling — the tally counts what the preview
counted, and refusals live in the notes. Two `skipped += 1` increments are
dropped (blocked, protected) and the companion refusal adds none; the value-is-
None and overwrite-off skips stay, because they depend on the run's flags, which
the preview cannot see. restored + skipped + failed now equals the item count the
user was shown — off by three before.
Behaviour change called out for review: test_credential_keys_are_never_restored
and test_auth_settings_are_never_restored asserted skipped == 2 and 4; both are
now 0, which is the point of the ruling.
16 new unit tests plus 2 integration tests. Nine of them are controls, because
over-refusal is the real risk of this change — the anonymous-broker and
anonymous-bind guards are load-bearing, not decoration.
The Git backup feature was push-only: there was no equivalent of the local
backup's Restore button, so recovering meant hand-downloading JSON files from
the repository. This adds the read side.
Providers gain list_commits / list_tree / fetch_files on the GitProviderBackend
ABC. GitHub implements them against the Git Data API and Gitea/Forgejo inherit
that unchanged; GitLab overrides for its own REST shape, including tree
pagination and subgroup path encoding. fetch_files is batched so the path ->
blob SHA lookup happens once per restore rather than once per file, and uses the
blobs API rather than contents because contents silently inlines only the first
1 MB.
The new GitHubRestoreService resolves HEAD to a concrete SHA up front, so a
preview and the restore that follows act on the same commit even if a scheduled
backup lands in between. Categories are applied archives -> spools -> settings
-> kprofiles: archives first because spool usage history references archive_id,
K-profiles last because they leave the database and publish over MQTT.
Restores never reuse the backup's primary keys. spool.id and print_archives.id
are bare autoincrement columns, so ids from an old backup very likely belong to
unrelated rows today; rows are matched on natural keys (tag_uid, then
tray_uuid, then a descriptive composite for spools; content_hash or filename
plus started_at for archives), inserted without an explicit id, and an
old_id -> new_id map rewrites the foreign keys in spool usage history.
created_at is carried across on insert so restoring the same backup twice
matches instead of duplicating. Dangling printer/project links are cleared and
reported rather than failing the row.
Settings restore re-applies the collector's credential denylist on the read
side, plus a pattern guard, because a backup taken before that denylist existed
can still contain secrets. Restored archives are metadata-only: the 3MF and
thumbnail bytes are not in a Git backup and print_archives.file_path is NOT
NULL, so inserted rows get an empty path and the UI says so.
Backup and restore take a mutex against each other; both write the same tables
and talk to the same printers. Restores are logged as GitHubBackupLog rows with
trigger="restore", which needs no migration and surfaces them in the existing
History card.
Cloud profiles are deliberately not a restore category. The collector never
actually writes cloud_profiles/*.json - it reads a "setting" list key the Bambu
Cloud API does not return - and the preset list it would write carries no
setting payload. Filed separately.
Permission github:restore already existed and is granted to Administrators, so
no permission changes were needed.
Tests: 125 new backend tests (provider reads across all four providers, the
per-category appliers, the API endpoints) and 13 frontend tests. Full suites
pass with no regressions; the 35 backend failures on Windows are byte-identical
with and without this branch.
The overlay at /overlay/{printer} draws live print data over a
full-screen camera view for OBS, a wall display or any browser source.
It has been tunable since it shipped -- which fields, what size, what
frame rate -- but only through query parameters documented in the wiki,
and temperatures were not among the fields on offer. The request asked
for temperatures first and for the field set to be selectable in the web
UI; this addresses both.
Nozzle, bed and chamber readings join the list. The target is drawn only
while the heater is still climbing, so a settled hotend reads "220°C"
for the rest of the print instead of the noisier "220 / 220°C" -- 219.6
against a target of 220 rounds to the same number, and repeating it says
nothing. Both nozzles appear on a dual-nozzle machine. They are drawn
whether or not a print is running, because a preheating printer is
exactly when they are worth watching, and each reading appears only when
the printer genuinely reports one: chamber temperature stays absent on
P1 and A1 models, which publish a chamber_temper with no sensor behind
it, so the overlay never puts a measurement on screen that does not
exist. Labels reuse the heater chart's strings rather than inventing a
second vocabulary for the same three things.
The feed sends an allow-list rather than the temperatures dict. That
dict doubles as the MQTT client's working memory -- derived heater flags
and private target-set timestamps live alongside the readings -- and an
overlay token is a narrower grant than a login, so it gets exactly what
the overlay draws and does not pick up fields as the dict grows. The
same chamber-sensor gate the full status payload already applies is
applied here. The integration test that asserts the payload's exact key
set, which exists to catch that surface widening silently, is updated
deliberately.
Temperatures are not in the default field set, so an overlay URL already
pasted into a scene renders identically after upgrading.
Settings -> API Keys -> Streaming Overlay now builds the URL: printer,
field checkboxes, size, frame rate, camera toggle, an optional token,
and a copy button. It persists nothing and calls nothing new -- the URL
is the configuration, which keeps a scene reproducible by copy-paste and
lets two displays show different fields off one token. Fields are
emitted in the overlay's own top-to-bottom order rather than click
order, and parameters left at their default are omitted, so the same
selection always produces the same URL. The preview alongside it stays
off until asked for: an always-live iframe would hold a subscriber on
the printer's single camera connection for as long as the settings tab
stayed open.
The preview needed one narrow security-header change. Every SPA route
sent frame-ancestors 'none', which is stricter than the SAMEORIGIN in
X-Frame-Options beside it and refuses even a same-origin frame, so the
preview showed Firefox's "another site has embedded it" page instead of
the overlay. The overlay path now sends 'self', mirroring /gcode-viewer,
which admits a framer only on this origin -- Bambuddy's own UI. Every
other path keeps 'none', and embedding the overlay from another host
still requires TRUSTED_FRAME_ORIGINS.
Starting the dryer on an AMS 2 Pro holding two PETG and two PLA spools
and picking PLA showed "PLA @ 45°C" for about a minute and then switched
to "PETG @ 65°C" for the remaining twelve hours.
Bambu never echoes back which filament or temperature a cycle is
running, so the badge reads the target cached when the command went out,
and that cache had been dropped. Between accepting the command and
settling its countdown the firmware publishes one update with the
remaining time at zero while the unit is still in its Checking phase --
the reporter's log has 720, then 0, then 719, and four seconds later the
same unit's info hex decodes to dry_status 2, Drying. The falling-edge
detector read that zero as the cycle ending. Losing the cached target
left the badge to guess from the first loaded slot, which was PETG, and
its RFID-recommended 65°C. The same false ending fired
on_drying_complete, so anyone with smart-plug auto-off-after-drying
switched on had power scheduled to cut one minute into a twelve-hour
dry; the reporter had it off, which is the only reason this reads as a
cosmetic bug.
A remaining time of zero now ends a cycle only when the unit is not also
reporting an active phase. dry_status comes from the same info hex
already parsed a few lines above, so this costs nothing to check.
Stopping and Error are deliberately not treated as active -- those
should end it -- and a unit that reports no phase at all still ends its
cycles, so the gate can only ever suppress on positive evidence that the
cycle is live. A suppressed edge leaves the remembered dry_time alone,
exactly as the #1462 absent-value skip does, so the push that really
ends the cycle still sees a non-zero previous.
The fallback guess is tightened to match. On a mixed unit the first tray
is evidence of nothing, and naming a temperature the cycle is not using
is worse than naming none, so it now answers only when every loaded
spool is the same filament and otherwise leaves the badge showing the
countdown alone. Both the websocket and REST status builders carried
their own copy of that loop; they now share one helper, which also takes
the temperature from the first slot that carries an RFID one rather than
giving up when slot 1 holds a third-party spool.
Printing a multi-plate file in different quantities per plate meant
queueing each plate separately and tracking the counts by hand: one
shared Quantity field cannot say "plate 1 once, plate 2 twice, plate 3
three times". Each selected plate now carries its own quantity, and the
submission becomes an order on a new Batches tab.
The point is the distinction the old flat batch could not express.
print_batch_plates stores how many runs of each plate were wanted,
separately from what was queued, so a run that fails, is cancelled or is
skipped does not satisfy a target -- the order goes on saying it owes a
print instead of quietly under-delivering. Queue remaining re-queues
exactly what is missing, for the whole order or one plate, by cloning
the most recent item for that plate: that inherits the printer or model
target, AMS mapping, filament overrides and print options along with the
validation they already passed, rather than re-serialising twenty fields
through a template that would drift from the model the first time
someone adds a column. Clones append to the end of the relevant
printer's queue and take the same advisory lock the add-to-queue route
does; positions are per-printer sequences, not global.
Cost is measured, not estimated. print_log_entries gains queue_item_id,
set where the queue item is already in scope, so each run's material and
energy are attributed through the item that produced them -- an
unrelated reprint of the same archive never lands in an order's total,
and a multi-plate order gets each plate's own cost rather than the whole
file's via the plate-scoped estimate from #2614. Before any run has
completed there is no honest figure, so cost reads as unknown instead of
a fabricated 0.00.
The Batches tab wires up GET /queue/batches, which has been unreferenced
since the batch MVP shipped, along with six locale keys that were
translated and never used. It is a separate tab because an order
outlives the queue that produced it: once its runs finish they leave the
active queue, so Queue and History each hold half the picture.
completed was not a reachable status before now, so every batch created
since April is still marked active however long ago its last print
finished -- 73 of them on the development install. A startup pass closes
out the finished ones: those whose runs all completed become completed,
and groupings whose items were all cancelled become cancelled, which is
what they are. Not applied to orders, which state their intent
independently of their runs and still owe the work. Only batches with
nothing queued or printing are considered, and repeating the pass also
catches an order whose last run landed while the process was down.
Batches with neither items nor targets are no longer listed at all --
empty shells left when a grouping's items went with their source
archive.
Dispatch applies the same source-file gates as POST /queue/. It creates
queue items, so without them it would be a weaker door to the same
outcome; the archive and library-file checks move into shared helpers
so a third route cannot drift from them.
The printed command only works from the directory holding the compose
file, which is the thing the user came to the page not knowing. Adds a
copy button, a saved Compose directory setting, BAMBUDDY_COMPOSE_DIR,
and best-effort detection from a bind mount's host path.
Compose records the directory on every container it creates, but reading
that label needs the Docker socket mounted in — root-equivalent access
for a convenience string. The mountinfo guess is a prefill only: its root
field is relative to the mounted device, so a compose dir on its own
mount loses that prefix, and nothing in the container can detect it.
The field is restricted to path characters. It is the one setting whose
purpose is to be pasted into a root shell, so "/opt/bambuddy; rm -rf /"
would otherwise render as a plausible update command.
The list and update endpoints serialised field by field and never named
cost / energy_kwh / energy_cost, so values Bambuddy had been recording
all along went out as nulls. Both now validate from the ORM row, which
removes the chance to omit a field rather than patching the three that
were missing.
Adds a Filament Used column plus a Columns picker for Cost, Energy,
Energy Cost and Finished, persisted per browser.
Also fixes the log view being unreachable with zero archives: the empty
state ran before the view check, hiding a log that outlives the archives
it refers to.
---
Sort the Print Log by any column (#2636)
Adds sort_by / sort_dir to the print-log endpoint, driven by clickable
column headers. Server-side because paging is: ordering the rows the
client holds would sort one page rather than the log.
Empty values are held last in both directions — Postgres sorts NULLs
high and SQLite low, so the same click would otherwise open on blanks
on one backend and values on the other. id DESC breaks ties so paging
through a low-cardinality sort can't repeat or skip a row.
Both are per-slice checkboxes, off by default, forwarded as the sidecar's
orient / arrange form fields. An unticked box is sent by omission: the
sidecar treats any present value as truthy, so a literal "false" would
have arranged every slice.
Arrange unions with the #1493 cross-class decision rather than replacing
it, and the per-plate slice-all loop is now keyed on the arrange flag
itself — the project-wide collapse belongs to --arrange, not to the
cross-class case. The loop also covers the embedded-settings path, whose
crash-retry is suppressed there since a single --slice 0 retry would
return one consolidated plate.
The edit dialog offered a printer picker and target-model dropdown for
an item with alternatives. Saving left a row with variants AND a
printer_id, and the scheduler's fixed-printer branch wins that race, so
it dispatched a row whose library_file_id is still null and failed in
the upload. PATCH now refuses printer/model changes on such an item —
comparing against the current value, since the dialog re-sends
target_model unchanged — and the route eager-loads variants, without
which the guard could not see them and every PATCH response dropped the
alternatives from its payload.
Names come from a shared helper now. A cross-model item holds neither
archive_id nor library_file_id until dispatch, so five separate inlined
fallbacks all rendered "File #null"; they now read "x1c.gcode.3mf +1
more".
The queue also grouped these under "Any H2D" — the first candidate
mirrored onto the row — filing a job under a printer it might never run
on. It groups as "Any H2D / X1C", matching the row beneath it.
Selecting several sliced files and pressing Print now creates one queue
item carrying all of them, instead of hiding the Print button the moment
a second file is selected. The printer picker is replaced by the ordered
candidate list, since choosing these files is already the answer to
"which printer" and the only question left is which is preferred.
Per-candidate configuration is the plate only. Model-based assignment
sends no AMS mapping — the printer is unknown until dispatch, where the
scheduler derives it — so a per-candidate mapping editor would collect
choices it then discards. Filament overrides stay shared: "this job
needs PETG" holds for every slice of the same job.
Adds Group as versions for durable grouping, a versions badge counting
the whole group rather than the rows on screen, and a queue card label
naming every model a pending item is waiting on.
Adds /library/variant-groups for declaring that several sliced files are
the same job for different printers, and a variants payload on queue
creation that turns such a set into one queue item with a candidate per
file.
The candidate set is validated as a set: one file per printer model, each
file sliced for the model it is offered as, and at least one model with
an active printer. A cross-model item deliberately holds no file of its
own, because print_queue.library_file_id is ON DELETE CASCADE and would
destroy the whole job when a single alternative is deleted.
Fixes internal printer-model codes never being resolved on queue create
and update: normalize_printer_model returns unknown input unchanged, so
the or-chain never reached the code map and a "C13" target matched no
printer and waited forever.
Skips candidates whose file is trashed or missing. Library deletes are
soft, and SQLite runs with PRAGMA foreign_keys off, so neither case is
covered by the schema; the hard-delete paths now also drop the rows.
Adds library_files.variant_target_model so a user can say which printer
a file without slicer metadata is for, kept out of file_metadata so the
assertion is never mistaken for parsed data.
The param was a bare bool, so /docs showed an undocumented boolean on
both upload routes. #2609 is about external integrations, and the
interactive docs are where those callers look — a docstring only reaches
someone reading the source. Wraps both in Query(False, description=...),
matching how this file documents its other query params.
Also records why these two routes take the flag per-request while the FTP
review flow and virtual-printer dispatch derive it from the VP-scoped
virtual_printer_archive_name_source setting, and drops the db_session
fixture the four new tests requested but never used.
Every field that takes a chamber target stopped at 60: the per-filament
chamber map and per-print override in Preheat & Heat Soak, the chamber
quick-select presets, and the printer-card chamber control. 60 is the
X1E's ceiling and the X1E was the only heated-chamber model when that
limit was written; the H2 series and X2D heat to 65, so the top of their
range was unreachable.
The ceiling now lives in one constant per side (MAX_CHAMBER_TEMP_C in
backend/app/utils/printer_models.py and frontend/src/utils/printer.ts)
rather than as a literal at each call site. X1E firmware clamps a higher
request to its own maximum, so a shared ceiling is safe.
Also fixes a live bug at PrintersPage.tsx:7985: parsePresetTriple was
bounded to 60 there, and it rejects the whole triple on any out-of-range
entry, so a saved 65 preset would have silently reverted the printer
card to the defaults while Settings still showed 65.
Maintainer review on #2610 flagged that upload-bulk would diverge from
upload if only the single-file route got the flag. Applies to every
file in the batch, same default-off behavior.
Promoting env_bool to strict rejection made BAMBUDDY_LOCAL_LOGIN=on raise
EnvOIDCConfigError uncaught on the login/forgot-password path -- a 500 on
the exact recovery endpoint the bypass exists to keep open. env_bool gains
a strict flag (default True for the startup OIDC reader); the local-login
caller opts out so an unrecognized value falls back to "off" instead.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016q8EAf9Rj7ZHL92sPnXYxy
_env_bool returned the default for anything outside {true,1,yes}, so
BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED=on silently read as OFF and
BAMBUDDY_OIDC_ENABLED=on silently disabled the provider -- the exact
opposite of what .env.example claimed. Unrecognized values now raise
EnvOIDCConfigError, caught in _apply_env_oidc_provider the same way a
bad DEFAULT_GROUP or a ValidationError already is: logged and left
running, never released on a typo.
Also promotes _env_bool to env_bool now that it has a call site in
auth.py, and corrects the boolean-parsing sentence in .env.example.
Saving a K-profile was fire-and-forget. set_kprofiles_batch published
and returned True, and the printer's extrusion_cali_set answer was
logged at DEBUG and dropped, so a write the printer refused was
reported to the user as saved (#2718, reporter @jmoore-skild).
The reason it could not simply be gated on: the answer itself was
wrong. Single-nozzle firmware returned result:"fail" with
reason:"invalid tray_id" on writes that demonstrably applied.
Measured against an X1C and an H2D over MQTT, the cause is the
tray_id:-1 Bambuddy itself put in the payload. Sending three
otherwise identical writes isolated it: tray_id:-1 fails, tray_id:0
succeeds, and cali_idx:-1 is accepted either way, so only that one
field is at fault. The H2D ignores the value entirely; the X1C
validates it, complains, and applies the write anyway. BambuStudio
always sends a real tray_id and defaults it to 0 for a manually
entered profile.
With tray_id:0 the acknowledgement is honest, and the printer echoes
back the sequence_id we sent -- confirmed for extrusion_cali_get,
_set and _del on both printer classes -- so it can be matched to the
write that caused it. Writes now return their sequence_id and the
routes await the verdict, turning a real failure into an error that
carries the printer's own reason. A printer that stays silent is
still treated as success: no answer is not evidence of refusal, and
firmware that never answers must not turn every save into an error.
Raises the ack to INFO. It sat at DEBUG, so the one line that
explains a failed save was absent from every support bundle -- the
same reasoning that put ams_filament_drying at INFO for #1447.
Also fixes extrusion_cali_set building its payload from
str(self._sequence_id) without incrementing first, reusing the
previous command's id. Harmless while nothing correlated on it,
fatal now that the write path does.
Adds supports_nozzle_flow_type() for the Standard / High Flow choice,
which the K-Profiles UI previously showed as "Not reported by
printer" -- not a value anyone can save. Most printers omit the
nozzle identity from their calibration table entirely, and the slicer
treats that as Standard rather than unknown; Bambuddy now does the
same and keeps the choice editable. The field is hidden only where
the model ships a single nozzle variant, using the slicer's own rule
(len(nozzle_volume) // len(nozzle_diameter) > 1 over the machine
preset) evaluated across every bundled Bambu profile. That puts only
A1, A1 Mini and A2L on the hidden side -- it is not the single-
versus-dual-nozzle split, since P1P, P1S, P2S, X1, X1C, X1E and H2S
are all single-nozzle and all carry two variants. Editing a profile
also no longer writes back an empty nozzle_id.
Wiki records that on printers which omit the field the chosen flow
type is discarded by the firmware and reads back as Standard, in
Bambu Studio as well, so it does not get filed as a bug again.
Enabling Cloud Profiles for a Git backup produced nothing, and said it had
worked. Two independent faults, either one sufficient.
The collector looked for a "setting" list. The Bambu Cloud listing endpoint
is keyed by preset type instead, each key holding private and public arrays,
so the loop body never executed once — and the entries carry no type of
their own either, which routes/cloud.py already knew: it takes the type from
the outer key and maps Bambu's "print" to process. Two bugs on one line.
It also asked build_authenticated_cloud for the credential store used when
authentication is disabled. With auth on, tokens live on User rows, so the
collector returned at "Cloud not authenticated" before ever reaching the bad
key. Every multi-user install was collecting from zero accounts.
Neither failure surfaced. backup_metadata.json recorded the configured flag
rather than the outcome, so it claimed cloud_profiles: true on runs that
wrote nothing, and the log read "Collected cloud profiles: 0 filament, 0
printer, 0 process" at INFO — which is exactly what a successful backup of
an empty account looks like.
Cloud profiles now come from every connected account across both clouds. The
toggle predates Orca Cloud entirely, and Orca has the same three preset
types, so both are collected and grouped the same way:
cloud_profiles/bambu/user-3/{filament,printer,process}.json
cloud_profiles/orca/user-3/{filament,printer,process}.json
Accounts are keyed by Bambuddy user id, "global" when auth is off. Never by
email: a backup repository can be public, and the Bambu listing's user_id is
dropped for the same reason. Both credential stores are read on every run,
because a Settings row survives someone enabling auth later and dropping it
would silently stop backing that account up.
Bambu costs one get_setting_detail per private preset. The listing is
metadata only, and without base_id and setting the backup is a list of names
that create_setting cannot rebuild from. Public presets are skipped — Bambu's
bundled catalogue is the same hundreds of entries for everyone, always
re-downloadable, not recreatable under your account, and would rewrite the
repository on every run. Orca needs no second call; its sync-pull carries
each profile's content inline. Where the Orca route drops a profile whose
content.type it cannot map, the backup writes it to other.json instead:
silently omitting a profile because Orca added a type is the same class of
bug as this one.
Failures are contained per account and per preset, and counted rather than
swallowed. A partial backup that looks complete is how this stayed invisible.
The metadata now reports what was collected, per cloud and per account, and a
run that collects nothing while the category is enabled warns with the reason
instead of an INFO line that reads like success.
The checkbox gated on the viewer's own Bambu sign-in, which is not the same
question as whether there is anything to back up — with auth enabled the
accounts belong to individual users, and an administrator who never signed
in personally saw the category disabled with plenty in scope. It now gates
on the total across both clouds and shows the counts. That comes from its
own endpoint rather than a field on /config, since /config answers null
until the first save and would disable the toggle during the very setup it
belongs to. Counts only, never identities.
One deliberate restraint. _build_authenticated_service clears stored
credentials when a refresh is rejected, which is right for a route — the
user is on the page and can pair again — and wrong for a scheduled job.
Orca reports every rejection with one composite reason ("unknown, expired,
revoked, or already used"), so a genuine revocation cannot be told apart
from a lost token-rotation race, and acting destructively on a signal that
cannot be disambiguated is the #2562 mistake in a different cloud. It also
gains nothing: the Profiles route hits the same failure and clears it then,
with the user present. Background callers now pass clear_on_auth_failure=
False and skip the account. A successful refresh is still persisted either
way — by that point the old token is consumed, so dropping the new pair
would break a working pairing for real.
Restore is not part of this. Nothing reads cloud_profiles/* yet; the format
carries base_id/setting for Bambu and content for Orca so that it can.
Round-3 review of the "Save AMS mapping" PR.
The queue item's ams_mapping was set unconditionally, on the reasoning that
honouring the slicer's own pick is a correctness fix rather than a feature.
It is both. Storing a resolved mapping makes _ensure_ams_mapping return
early, so _compute_ams_mapping_for_printer never runs — and that function is
where prefer_lowest_filament lives, along with the AMS-filament-backup gate
that qualifies it (#1766), the inventory-remain overrides, and the per-slot
force-colour overrides. Every existing queue-mode VP pointed at a printer
would have quietly lost all of it on upgrade, without a setting to turn it
back on.
So save_ams_mapping now gates the queue item too, not just the archive
persistence. Off is exactly the old behaviour. The correctness case the PR
was written for — two spools of the same red PLA, and the slot the user
picked in the slicer thrown away — is still fixed, for anyone who asks for
it.
Force color match wins over it when both are on. Its only effect on a
fixed-printer item is the filament_overrides written onto the queue item,
and those are read inside the function a stored mapping skips, so the two
toggles sitting next to each other on the same card silently cancelled. The
dispatch now matches strictly, as asked, while the slicer's pick is still
saved onto the archive — that is what the toggle's name promises, and a
later reprint is a separate decision from this print. The queue-add fallback
applies the same rule to a request that carries force-colour overrides.
A mapping shorter than a plate's highest slot id cannot address that plate's
own slots, and _ensure_ams_mapping would have kept it anyway, since it only
rejects an all-unresolved one. Each plate now checks the length it needs and
falls back to a computed mapping if the array does not reach. Bambu Studio
sends a file-global array, so this normally never fires; it also means a
multi-plate Send All degrades safely if that ever stops being true.
The badges claimed more than they delivered. Both rendered whenever a saved
mapping existed, ignoring which printer it belonged to, while the tooltips
promised the reprint would reuse those exact spools — true only on the
printer the trays were resolved against. The queue row's flag is now
computed against that row's own printer, which is precisely when dispatch
reuses the mapping, and the archive card names the printer instead of
implying any of them will do. It hides itself when that printer no longer
exists. Retranslated in all 13 locales.
Frontend tests, which the PR had none of. The printer-scoping rule is now a
pure function rather than an inline expression, covered for the mismatched
printer, the no-printer-selected case that would otherwise compare undefined
against undefined, and malformed extra_data. The toggle's undo bookkeeping
is covered for unresolved slots, short mappings, and hand-made picks —
preserved when the toggle never wrote that slot, replaced when it did, which
is behaviour worth pinning either way.
Also reverts all three queue-mode switches when a save fails, not just the
new one; without it the card shows a setting the server rejected.
A bundle described everything except the process it runs in. So a report of
memory climbing over days until the OOM killer fires arrives with no way to
act on it: the numbers that name the mechanism only exist while it is
happening, and by the time anyone asks, the container has been restarted.
The new `process` section carries what actually separates the candidates.
Resident against virtual memory: 650MB RSS with 12.9GB VMS is address
space — thread stacks or allocator arenas — not a heap full of live data,
and that reading is the opposite of the one the reporter drew from the same
figures. Thread count and child-process count then split those two apart,
and a census of live objects by type names what a growing heap is filling
up with. Open files, sockets and uptime round it out.
Three constraints worth keeping:
The heap census is skipped above 2GB. gc.get_objects() materialises every
tracked object, so it costs most on exactly the process that can least
afford it — a bundle generated to diagnose runaway memory must not be the
allocation that tips the host over. Everything else is still collected, and
the skip is recorded with its reason rather than silently omitted.
Children are recorded by executable name only. An ffmpeg command line
carries the camera URL, and with it the camera's password.
Collection runs off the event loop and every metric is independently
best-effort. psutil raises on hardened kernels and in restricted
containers, and the bundle is how someone reports a problem in the first
place — it has to be produced even when half the numbers are unavailable.
This does not fix#2734, and nothing here should be read as having found
its cause. The bundle's own evidence contradicts both proposed causes: the
orphan janitor ran 7 times in 26 days over 725 stream-ends and killed no
orphaned ffmpeg, which is not the #776 signature; and the 5 "database is
locked" errors all fall between two OOM kills, making them a symptom of the
memory pressure rather than a source of it.
A heavy MakerWorld model — one Bambu Studio also takes a long time over —
failed after five minutes with "Slicer sidecar unreachable". The sidecar
was reachable the whole time and still slicing when we hung up on it.
SlicerApiService carried a hardcoded 300s timeout, passed to httpx as a
bare float so it covered connect, read, write and pool alike. On a single
long request that is not a health check, it is a cap on how long a model is
allowed to take. And because httpx.ReadTimeout subclasses RequestError,
expiry landed in the same handler as a refused connection and was reported
as an unreachable sidecar — so the reporter went and updated their sidecar
container, which was never the problem.
The information to do better was already being collected. _poll_progress
polls /slice/progress/{id} once a second alongside the blocking POST to
drive the live progress toast, so at minute five Bambuddy had fresh
evidence the slicer was working. It killed the request anyway.
So the read timeout comes off the HTTP call and the poller supervises
instead: the deadline moves forward on every progress update, and only
genuine silence ends the wait. A model that keeps reporting runs to
completion however long it takes. Connect and pool keep short timeouts —
a sidecar that will not accept a connection is unreachable and should
still say so quickly.
Only a *changed* progress payload counts as alive. The sidecar re-serves
its last snapshot on every poll, so counting repeats would leave the
watchdog unable to detect a stall at all.
The window is floored at three poll intervals: liveness can only be
observed as fast as the poller ticks, so anything shorter would expire in
the gap between two polls and fail every slice instantly.
New setting slicer_stall_timeout_minutes (Settings > Workflow > Slicer),
default 15, range 1-240, alongside the sidecar URL and gated on
use_slicer_api like its neighbours. Sidecars too old to report progress
have no liveness signal, so for those the same number bounds total elapsed
time — the old behaviour, configurable and no longer 300s flat. The
message says which case applies and where to change it.
SlicerTimeoutError is its own type and maps to 504, not 502: the sidecar
answered throughout, we stopped waiting. Connection failures keep
SlicerApiUnavailableError. The preview slice path gets the same treatment.
_local_login_env_bypass() re-inlined the same {"true", "1", "yes"} set
oidc_env._env_bool already enforces, so the typo-guard existed in two
places. oidc_env has no module-scope import of models (its model
imports are lazy inside apply_env_oidc_provider), so importing
_env_bool at module scope here is not a cycle -- confirmed by
importing backend.app.main and this module directly.
Deleting a print that belonged to a project left it on the project page as
a card with a missing thumbnail, and there was no way to remove it.
Deleting a print is a soft delete by default (#1343): the files go from
disk, the row stays so global Quick Stats keeps counting its filament,
time and cost. Every other consumer filters those rows out. The projects
module filtered none of them — the only deleted_at check in the whole file
was for LibraryFile — so a deleted print kept its project_id and kept
being listed, pointing at a thumbnail that no longer existed. The same
broken previews appeared on the overview cards, and in the timeline, where
the entry links to an archive that no longer opens. Unassigning was
impossible because the only UI that can change a print's project lives on
the Archives page, which correctly hides deleted prints: visible on the
project, unreachable from anywhere.
All eight project-scoped archive queries now filter, counts included. That
last part is a deliberate divergence from #1343, where the whole point of
the soft delete is that the contribution survives: a project is a piece of
work with a definite membership, not a lifetime total, so a project that
lists eleven prints must not claim twelve. The reasoning is recorded at
the constant so nobody later "fixes" it back.
remove_archives_from_project keeps working on hidden rows on purpose — it
is the repair path for links written before this. The BOM print_name
lookups are left alone; naming a since-deleted print is still correct.
Two more consumers had the same gap. The CSV/Excel export handed back rows
the interface says are gone — filtered at the base query, since the export
is the list you are looking at saved to a file. Per-project failure
analysis measured a failure rate against prints deleted from the project,
and disagreed with the project's own numbers; only the project-scoped
branch filters, global analysis still counts every run including orphans
as #1390 established.
Finally, the project page needed a manual reload to catch up. staleTime is
60s and the delete mutations invalidated only ['archives'], so a project
visited within the minute served its cached copy, print still there. The
project-assign mutations had the mirror-image bug: ['projects'] refreshed
the overview cards but never ['project', id]. Both now go through one
shared helper covering every project-derived key, as bare prefixes so all
cached project ids are matched.
Obico's ml_api container takes an optional ML_API_TOKEN environment variable.
With it set, ml_api/auth.py answers a bare 401 to any request whose
Authorization header isn't "Bearer <token>"; with it unset it ignores the
header entirely. Bambuddy never sent one, so pointing it at a protected server
meant deleting the token there — which the reporter had set for their Home
Assistant integration and did not want to undo.
Settings -> Failure Detection gains an ML API Token field. When it is empty no
header is sent, so an unconfigured install's request stays byte-identical to
what shipped before the setting existed.
This failed in the worst possible way, and that is the more important half of
the change. Obico decorates /p/ with token_required but leaves /hc/ open. Test
Connection pinged /hc/, so it reported success against a server that was
rejecting every real detection call, the settings looked right, and detection
silently never ran. The only symptom was a generic "ML API call failed" buried
in the status card.
So the test now proves what it claims. After health passes it probes GET /p/
with no img parameter: the auth decorator runs before the handler, so 401 means
the token was rejected and 422 ("Invalid request params") means it was
accepted. No inference work is done either way. A probe that itself errors
reports the token as unknown rather than as working — the UI says it could not
be checked instead of claiming success.
The detection loop checks for 401 before raise_for_status, so a rejected token
is reported as a rejected token, naming the setting and the environment
variable, instead of surfacing "401 Unauthorized" with no hint of what to do.
The message never contains the token; a test pins that.
The setting name carries "token", so the support bundle's keyword redactor
masks it with no new rule. Resolving "field omitted" to the saved token is the
route's job, keeping test_connection a pure outbound call with no database
access.
Second fix, same issue: support bundles misreported which printers Obico
watches. The bundle split obico_enabled_printers on commas and read an empty
value as "no printers". The settings UI writes a JSON array, and empty means
*all* printers — the default — so a working Obico setup showed obico_enabled
false against every printer in its own bundle. That is the reporter's bundle
exactly, and it points anyone reading it at the wrong subsystem. The bundle now
parses the setting the way ObicoDetectionService does, keeps a comma fallback
for any install that stored the legacy shape, and factors in the global switch.
The photo fired the moment layer_num reached total_layer_num. That edge is
where the printer *starts* its final layer, not where it finishes it: the
reporter's H2C capture shows it arriving at 92% with mc_remaining_time=2,
three minutes and seventeen seconds and one filament change before the print
actually ended, so the frame caught the toolhead mid-print over the model.
The trigger also latched _finish_photo_captured, which locked out both the
stage-22 and FINISH triggers for the rest of the print — so on firmware that
never reports an end-of-print filament unload (H2C and A1 Mini confirmed)
nothing could replace the bad frame.
Remove the last-layer trigger. The photo is now taken at the FINISH-state
trigger, which every model sends and which lands after the toolhead parks.
Since Bambu's end G-code drops the plate ~100mm just before that, restore the
framing before capturing: absolute G90/G1 Z to max_z_height + 10mm clearance,
settle, capture, then drop it back so the print is as reachable as the printer
left it. Absolute is the safety argument — that Z is a height the toolhead
occupied seconds earlier, so it is inside the travel limits by construction and
leaves the nozzle above the part, and it is unambiguous across model families
because Z is the nozzle-to-bed gap whether the bed moves or the toolhead does.
M211 is never touched (#2579). This is what #1145, #1397 and #1565 asked for.
The height is only trusted when two independent sources agree: the archive is
matched by the finished print's subtask_name by equality (not LIKE, so "Cube"
cannot resolve to "Cube v2"), and its layer count from the 3MF must match the
layer count the printer reported over MQTT. Matching on "most recent archive
for this printer" was not safe — on_print_complete pops the _active_prints
binding concurrently, and a print Bambuddy failed to archive would have
resolved to its predecessor. A wrong height is the one failure that could drive
the nozzle into the model.
The move is additionally skipped when the print height is unknown, when a queue
item is pending for the printer, when the printer has left FINISH, and when the
new finish_photo_restore_plate setting is off.
for every FINISH-state capture — which is what shipped the mid-print photo —
the bank is used only when the dispatcher recorded that it injected End G-code
into this print, since a SwapMod snippet may have ejected the plate. The flag is
handed over in two steps (mark_pending at dispatch, adopt at print start) so it
can never outlive its print: a job started from the slicer or SD card adopts
False rather than inheriting its predecessor's answer. Those prints also skip
the plate move outright, bank or no bank.
The bank now refreshes on mc_percent advances as well as layer changes, via a
new on_print_progress callback. Layer changes stop the instant the final layer
begins, which left the #1867 fallback frame stale by the whole length of that
layer; progress keeps ticking there and freezes before the End G-code runs, so
a swapped plate still cannot reach the bank. The last-layer throttle exemption
is dropped, since it would now fire a grab on every percent tick.
On the timelapse path the moment producer returns early, so the consumer does
the restore itself before its live-grab fallback — the documented usual outcome
on P1-series, where the video has not transferred by the time the notification
goes out and the shipped photo was of an already-dropped plate. The two waits
are now derived from the settle window and the video poll timeout rather than
hardcoded; at the old flat 75s that fallback was guaranteed to be cut off
mid-settle.
extract_max_z_height_from_3mf reads only a bounded prefix of the plate G-code,
since a sliced plate is routinely tens of megabytes and the header is ~40 lines.
It returns None for missing, unparseable, zero and negative values so callers
must treat "don't know" as such rather than defaulting.
Round-2 review fixes for #2700.
Blocking: the toggle didn't actually gate the archive write. archive.py's
promotion fired for any print_data carrying ams_mapping, but bambu_mqtt's
request-topic interception captures ams_mapping unconditionally for every
print source (slicer-direct LAN prints included). Since main.py's
real-printer auto-archive path forwards the full MQTT payload as
print_data, every archive on any install — VP or not — grew
extra_data.slicer_ams_mapping. Fixed by replacing the print_data-sniffing
with an explicit `slicer_ams_mapping` param on archive_print() that only
the VP-queue path (already gated on save_ams_mapping) ever passes.
Blocking: a saved mapping could get reused on a printer it was never
resolved against — tray IDs only mean something relative to one printer's
AMS layout. extra_data.slicer_ams_mapping is now stored as
{mapping, printer_id} instead of a bare array:
- add_to_queue's fallback only fires when the reprint's target printer_id
matches the mapping's origin printer.
- The frontend's archiveAmsMapping only surfaces (and the Mapping button
only appears) when the print modal's selected printer matches too.
- A model-based VP (target_printer_id=None, no MQTT bridge to any real
printer) never stamps a mapping in the first place — there's no live AMS
layout for the slicer to have resolved tray IDs against.
Also from review:
- Multi-plate archives now get the Mapping button too (the per-plate
FilamentMapping loop was missing archiveAmsMapping entirely).
- Added coverage for the previously-untested late-MQTT archive patch path
(_restamp_recent_queue_item), including the model-based-VP skip case.
- usingArchiveMapping now also resets on printer change, not just
plate/archive (it already worked via the printer-scoping above, but is
now an explicit dependency too).
- The Mapping button's revert (OFF) now undoes only the slots it itself
set, not every manual pick in scope — matches the comment above it.
- Added a comment on why negative-value slots (external spool) are
skipped rather than cleared when applying a saved mapping.
Lets a reprint reuse the AMS slot the slicer itself picked, instead of
re-deriving one from the file's static type/color.
When a Print Queue VP has "Save AMS mapping" on, the slicer's own
live-resolved ams_mapping (from the project_file MQTT command) is
persisted onto the archive as extra_data.slicer_ams_mapping. A later
reprint can reuse it via a new "Mapping" button in the filament-mapping
panel — one click snaps every slot to the saved pick, click again
reverts to auto-match. Archive cards and queue rows get an "AMS mapping
saved" badge so it's visible beforehand. add_to_queue also falls back
to the saved mapping automatically when the caller sends no explicit
ams_mapping (e.g. a plain reprint with no per-slot edits).
The queue item's own ams_mapping (used for that dispatch) is still
captured unconditionally whenever the slicer provides it — that part is
a correctness fix, not gated behind the toggle. Only the archive
persistence for future reprints is opt-in.
Split out from the original combined PR per review: this half is
genuinely opt-in and low-risk (#2684). The dispatch-time validation
gate that keeps a stored mapping honest (#1308) changes behaviour for
every existing user and will land as its own PR.
Review fixes applied:
- _extract_slicer_ams_mapping_json: dropped the unreachable `v is None`
arm and rejected bool explicitly (isinstance(v, int) accepts bool).
- Translated the Russian docstring text to English.
- save_ams_mapping's model comment moved to a trailing comment on the
column line, matching the file's convention.
- usingArchiveMapping now resets when the plate or archive changes, so
the Mapping button can't read ON against a mapping it never applied.
- Translated "Click to change slot assignment" and "Re-read".
- add_to_queue's fallback is now called out explicitly in code comments
and covered by three new integration tests (fallback fires, explicit
mapping wins, unrelated extra_data doesn't false-trigger).
Closes#2684
device.airduct is pushed field by field - the modeCur handler reads it with
an "in" check for that reason - so a frame can carry parts without carrying
every fan. Absence in that list is what tells us a kit is not fitted, and
taken from a truncated frame it made both accessory badges vanish mid-print
and started rejecting fan=aux2 on a printer that has the fan.
A parts list now counts as a full inventory only when it carries ids 1 (part
cooling) and 2 (aux). Neither is optional on a machine that reports an
airduct at all, and both appear in every layout in the support-package
archive - P2S base 1,2 / P2S+kit 1,2,3 / X2D 1,2,3,10 / H2C,H2D,H2S 1,2,3,6.
Anything narrower is a diff frame: its speeds are applied, presence is left
alone. Presence can still be added from a partial frame; only retraction
needs the full list, so a kit that really is removed still disappears.
Also compose showChamberFan from both model lists rather than branching
between them, so the P2S/X2D entries in MODELS_WITH_CHAMBER_FAN stay
reachable instead of reading as dead, and note in the fan-speed docstring
that the aux2 gate also rejects between connect and the first airduct push.
The filament list is positional from the modal down to the CLI's
filament_N.json parts, but for a source that already carries slice_info the
requirements endpoint returns only the slots the plate consumes. A
MakerWorld model declaring four filaments and painting with slot 4 alone
therefore showed one dropdown, whose PETG pick the CLI bound to slot 1 —
slot 4 sliced with the profile baked into the source, and the print came out
PLA.
The endpoint now takes full_slots, which widens that answer to every
project slot with used_in_plate flags, and only the slice modal passes it.
Print-time AMS matching shares the endpoint and keeps the used-only list, so
it still asks for exactly the spools the job needs.
setInterval does not await an async callback. Slicing a large project
blocks the backend for seconds, so poll ticks piled up behind one stalled
request, each holding a snapshot taken while the job was still active.
They resolved together, and every one of them ran the completion path —
one toast and two query invalidations each. A 20s stall against the 1.5s
interval produced 13 "Sliced X" toasts from a single slice.
Only one poll round is now in flight at a time, which also stops queueing
requests against a backend that is already saturated. Completion is
recorded once per job id, and a round still awaiting a response when the
effect tears down now returns instead of acting.