Commit Graph
948 Commits
Author SHA1 Message Date
maziggy f3b1c59169 fix(orca-cloud): close the HTTP client when an authenticated build fails
OrcaCloudService owns an httpx client from construction, and every path in
_build_authenticated_service after that point can raise: no stored refresh
token, a rejected refresh, an unreachable Orca, and the token-rotation write.
On success the caller closes the client. On failure nobody is ever handed it,
so all four paths leaked one into the connection pool.

That went unnoticed while the only callers were routes, where the trigger is a
person retrying a broken sign-in a handful of times. It stopped being harmless
in 9434875f, which added a caller in spool assignment -- one build per
Orca-referenced spool, failing on every assignment for as long as the stored
credentials cannot be refreshed.

The unwind guard catches BaseException rather than Exception: a cancelled
request leaks the client just as surely as a failed refresh, and cancellation
during shutdown is exactly when dangling sockets are least welcome. The close
inside it is guarded in turn, so a failing cleanup cannot replace the error the
caller needs to see -- least of all a CancelledError, which has to keep
propagating for cancellation to work at all.

Six tests. Four fail against the unguarded builder, verified by reverting the
guard and re-running; the other two pin the surrounding contract (a failing
close must not mask the real error, and a successful build must leave the
client open for its caller) and pass either way. The shared _expired_service
helper now gives the mock an awaitable close(), so the four pre-existing
refresh tests exercise the same path.

Also corrects two comments and the changelog entry from 9434875f, which
overstated what the captures support. They claimed Bambu Cloud returns a
preset's filament_id in either of two places and only one was read. The
responses recorded in #1053 show something narrower: a Studio-created preset
carries it on the envelope, and an Orca-created one has none at all -- the
envelope says null and `setting` is a delta from the base. The `setting` lookup
stays as belt-and-braces for a shape no captured response has needed yet, but
it is not why a custom profile reached the slicer as its base. That is the
OrcaSlicer preset format having no filament_id field, filed upstream as
OrcaSlicer PR #13315.

The eight-character truncation is now evidenced across three models rather than
one -- an A1 storing PFUS9DDC of PFUS9DDC938FE3AB8F, a P1S storing PFUS7A65 of
PFUS7A65290D3DADC4, and an H2D storing 8219C45D of an Orca profile UUID.
2026-09-07 10:39:10 +02:00
maziggy 9434875fa1 fix(ams): resolve a custom filament's own id from every preset source (issue #3003)
A custom filament profile reaches an AMS slot as itself through exactly one
field, tray_info_idx, and every source we can read that id from was reading it
from the wrong place or not reading it at all.

Bambu Cloud returns a preset's own filament_id either on the response envelope
or inside the preset JSON under `setting`, and only the envelope was read.
Presets of the second shape fell through to the base_id branch and reached the
slicer as the Bambu filament they inherit from. filament_type next door already
handled both spreads; filament_id now does too.

Orca Cloud was absent from the resolver entirely. A spool stores the bare
profile UUID, which matched no branch and fell through normalize_slicer_filament
-- a function that passes anything it does not recognise straight through -- so
a 36-character UUID went into the field. Orca profiles carry their own
filament_id in the slicer JSON that OrcaProfileDetail already exposes under
`setting`, so the lookup is the same one the Bambu branch does. It is
best-effort: no pairing, a dead token or a missing orca_cloud:auth permission
degrades to the fallback rather than failing the assignment, and it passes
clear_on_auth_failure=False because a background caller cannot tell a real
revocation from a lost refresh-rotation race.

configure_ams_slot sent the cloud setting_id as tray_info_idx when it found no
real filament id. That field is 8 characters on the printer -- exactly the width
of a local preset id, less than half a cloud one. Measured on the reporter's A1:
sent PFUS9ddc938fe3ab8f, the tray read back PFUS9DDC, acknowledged as a success.
The slot then resolved to nothing, so the slicer showed Generic anyway and the
calibration table, keyed by the same field, lost the slot. It now falls back to
the slot's existing filament id or the generic for the material, and the route's
guard was aligned with the resolver's so both refuse the same four shapes from
one shared definition.

This reverses the contract #1053 pinned. Six tests asserted that the PFUS
belonged in tray_info_idx; the A1 capture shows it never worked, so they were
rewritten with the measurement in their docstrings.

Verified against 874 AMS trays across twelve models in the support archive: 92
already carry a custom "P" + 7 hex filament id, which is what confirms the
mechanism works and this is a lookup failure rather than a platform limit. No
tray on any model carries a setting_id, so a profile with no filament_id of its
own still cannot be told apart from its base.
2026-09-07 10:15:06 +02:00
maziggy 2e405afcd1 Decide whether a 3MF is sliced by looking inside it (issue #2993)
An archive that showed the green GCODE badge could re-import into the
File Manager as a source-only project with no Print button, seemingly at
random.

Nothing was ever lost from the file. The download serves the stored
bytes verbatim and the G-code was still in the zip; the two sides simply
asked different questions. Archives looked inside the file. The library
looked at the filename. So a sliced 3MF stored as Foo.3mf rather than
Foo.gcode.3mf earned the badge and lost the Print button, and which one
you got depended on how the print had reached the printer -- a slicer's
LAN send names it .gcode.3mf, a per-plate export or a cloud-dispatched
print does not.

Both sides now ask one shared predicate about the zip itself, and every
route into the library classifies on content. Only the central directory
is read, and only when the name has not already settled it, so ingest
costs nothing extra -- the external scan opens each 3MF for its
thumbnail regardless. Rows already stored are re-checked once, internal
ones only: an external row points at a mount that may be slow or absent,
and startup is the worst place to discover that.

The Slice action moves with it. Its refusal to slice an output was as
name-bound as the Print gate, and without that a file that correctly
gained a Print button would have offered to re-slice its own G-code.
2026-08-29 14:14:26 +02:00
maziggy 7c10412f99 Refuse a same-named 3MF that contradicts the running print (issue #2957)
When a print's own 3MF cannot be fetched the usage tracker borrows one from the
library or a previous archive, matching on the filename stem. That is far weaker
evidence than it looks: Bambu Studio writes the printer-side filename from the
project's Title metadata, so every plate of a project reaches the printer under
one name however the file was renamed on disk.

The reporter's single-filament job was handed a previous archive's three-filament
plate. Three spools were debited for material that was never extruded, and
nothing on the archive said the numbers were someone else's.

A candidate is now rejected when it positively contradicts the print - a
different plate, or a filament count the slicer's ams_mapping disagrees with -
and the accepted one is logged with both expectations. Only on a contradiction:
the plate needs firmware that echoes it and the count needs a print command
Bambuddy saw, and refusing everything uncorroborated would retire the fallback
recovery this same issue asked for.

The count is scoped to one plate or not compared at all. Unscoped, the filament
reader collects every <filament> in the file, and that sum against one plate's
count would reject every multi-plate library upload on exactly the firmwares
that cannot tell us the plate.

-----

Give a download the time the file needs, and one printer at a time (issue #2957)

ftp_timeout is handed to every download as both the socket inactivity timeout
and the whole-transfer deadline, which makes its 30s default a cap on how big a
file a printer may serve. The reporter measured one 5.4 MB 3MF at 45s off a worn
P1S SD card and 25s off a new one, and a 15.15 MB 3MF at 105s. None of those
links were broken - they were slow, which is what the inactivity timeout exists
to tell apart.

download_to_file already asks for SIZE. It now reports it, and the total
deadline follows the file at the 25 KB/s floor _upload_deadline has used since
do, so #2572's cap on the executor queue wait is untouched. Capped at 300s for a
reason that is not about FTP: on_print_start holds a pooled DB connection across
its whole 3MF hunt.

Downloads also take turns per printer now. He watched Bambu Studio lose its own
connection while Bambuddy pulled a 12 MB 3MF, and a later log caught two
Bambuddy downloads of the same file overlapping at print start. The gate is
soft - whoever cannot have it within 30s goes anyway, because a print losing its
3MF to queueing is worse than the contention, and a soft gate cannot deadlock.

It is also meaningful for the first time. The 90s cap on a multi-path lookup
returned while its worker kept walking the remaining paths, still on the
printer's socket; that walk is now cancelled and waited out before the printer
is handed on.

-----

Look in the shared 3MF cache again before each cover retry (issue #2957)

The cover endpoint and the print-start archive flow share a cache so whichever
fetches the 3MF first hands it to the other (#972). The cover consulted it once
on the way in, then retried for up to two and a half minutes without looking
again.

In the reporter's log the archive flow published the file 42 seconds into that
sequence and the cover's third attempt still pulled its own 5,250,969-byte copy,
off a printer that was mid-print on the same SD card.

A file picked up that way is left alone rather than re-registered under this
endpoint's own name or deleted on the way out. It is the archive flow's.
2026-08-29 10:46:36 +02:00
maziggy d4477e9b71 Log ffmpeg's error instead of its build banner
ffmpeg opens every run with ~20 lines of version and build banner and prints
its diagnosis last, so the stderr[:200] eight of the nine call sites used kept
the banner and threw the error away. The reporter's twelve capture failures all
read "ffmpeg version 7.1.4 ... configuration: --prefix=/usr --extra-version=",
identical on every install; the exit code was the only usable byte.

The banner-stripping summariser written for #925 lived private to the camera
route. It now lives in backend/app/utils/ffmpeg_output.py and every ffmpeg and
ffprobe stderr goes through it. Two things the scattered copies also got wrong:
four logged the input URL unmasked, publishing a printer access code or camera
password, and four called a bare .decode() on bytes ffmpeg copies stream
fragments into.

-----

Delete the files a no-3MF archive owns, without taking a printer folder

Both delete paths derived the directory from file_path, which such an archive
does not have, so they removed nothing and logged it at ERROR under a SECURITY
banner. That was true when the archive was an empty row and stopped being true
once one could hold a timelapse and finish photos in <archive_dir>/<id>/ and an
uploaded source in archive/no_source/<id>/.

The two are cleaned up by different means, because <archive_dir>/<id> shares a
namespace with the per-printer folders: a normal archive lives at
<archive_dir>/<printer_id>/<timestamp>_<name>/, so archive/1 is printer 1's
folder and also the directory the shared helper hands archive id 1. Ids come
from unrelated sequences, so the first few archives collide with the printers
on every install, and an rmtree there takes every print that printer made --
measured on a scratch tree. no_source/<id> is a level deeper under a name no
printer id can take and is removed whole; the id-named directory gives up only
its photos subdirectory and the video the row records, then goes only if that
left it empty. The depth guard moves from one to two for the same reason: a
file_path that lost a path component could point the delete at a printer
folder, and no archive directory has been one level deep since the first
commit.

Hard delete had its own copy of these rules, which the helper's docstring says
it exists to prevent, and it had diverged -- it skipped the print-log thumbnail
cleanup whenever a guard tripped.

-----

Stop the RTSPS proxy leaving a handler behind at shutdown

asyncio.start_server keeps only a weak reference to the connection callback's
task, so a handler still awaiting its forwarders could be collected while
pending -- "Task was destroyed but it is pending!", at ERROR with a traceback
into camera.py, once every few hundred snapshots. Teardown had the matching
gap: server.close() leaves established connections running, so the close waited
on a handler that only finishes when the peer drops, and ffmpeg has already
been reaped by then.

Handlers are held for as long as they run and cancelled at shutdown, which is
Server.close_clients() by hand -- that landed in 3.13 and Bambuddy supports
3.10. Both the snapshot path and the streaming endpoint share the shutdown.
2026-08-29 08:51:33 +02:00
maziggy 0d21239e18 Send AMS tray colours as uppercase hex (issue #2987)
Assigning a spool to an AMS slot unassigned it again seconds later, and
the slot's colour changed at the same time. It presented as Bambu Studio
and Bambuddy fighting over the slot. The reporter's log shows Bambuddy
losing to itself.

P1S firmware 01.10.00.00 reads every lowercase hex letter in an AMS
tray_color as a zero, and hides it completely: the command response
echoes back the value that was sent and reports result "success", so
only the next AMS push says what was really stored. The spool-assign
path sent spool.rgba verbatim and that column stores lowercase. From the
bundle:

  sent 09ff00ff  ->  AMS reports 09000000
  sent ff5100ff  ->  AMS reports 00510000
  sent 090000FF  ->  AMS reports 090000FF

That is the visible colour change, and it is also what deleted the
assignment. The auto-unlink sweep asks whether the slot still matches
the spool assigned to it; the mangled colour no longer did, so the
assignment Bambuddy had made four seconds earlier was removed.
colors_similar('09000000', '09FF00FF') is False, which is the whole of
it.

Re-assigning could not recover, because the Configure Slot dialog seeds
its colour from whatever the printer currently reports. It wrote the
mangled colour back and cemented it, which is the loop the report
describes in its steps 4 and 5.

Colours are now uppercased where the command is assembled rather than in
each of the four routes that configure a slot. A caller that forgets is
exactly how this arrived. Nothing else changes: no padding, no invented
alpha, no six-to-eight widening, and tray_type and tray_sub_brands keep
their case, where it carries meaning -- "PLA Matte" is a product line,
"PLA MATTE" is not.

Two paths deliberately left alone. The developer-mode probe re-sends the
colour the printer itself just reported so that the probe is inert;
uppercasing there would turn it into a write. And the Virtual Printer
forwards the slicer's own command verbatim -- Studio could in principle
hit the same firmware bug, but nothing here evidences that it sends
lowercase, and rewriting a slicer payload inside a transparent proxy is
not a change to make on a hunch.

Two more defects from the same log.

A spool with a brand and no subtype was configured with the string
"None" in its name: the branded branch interpolated spool.subtype
without checking it while the unbranded branch guarded it, so
"Sunlu PLA Matte None" went on the wire and into Studio's display.

And the FTP log is readable again. A 426 whose bytes Bambuddy has
already verified against the printer is how Bambu FTPS normally ends a
transfer, not a fault, so it drops from WARNING to INFO. It fired 54
times in this one bundle, every one followed by a completed upload, and
it was burying the 26 TLS handshake failures in the same log that
actually cost the reporter two prints. A 426 whose bytes do not verify
is still an error and still fails the upload.

The handshake failures themselves are printer-side FTPS cool-off under
load and are not touched here.
2026-08-28 12:18:27 +02:00
maziggy e9daa2124e Match slicer presets on what they declare, not what they are named (issue #2982)
The internal slicer picked PETG for a PLA plate and an A1 process for a
P1S. Both come from the sidecar's bundled-profile listing, fixed in the
sidecar repo; this is the consuming half plus the hardening that keeps an
older sidecar degrading rather than breaking.

Standard-tier presets now carry the compatible_printers the sidecar
reports. That list is the only truthful account of which printer a preset
belongs to, because the bundle ships no process preset named after a P1S,
an X1, an X1E or an H2D Pro -- all ten of the P1S's are named "@BBL X1C"
and name the P1S only in that list. Reading the printer out of the preset
NAME therefore made a P1S look like it had no compatible process at all:
all 198 hid behind "Show all" and the auto-pick fell through to an
alphabetically-first 0.06mm Fine @BBL A1 0.2 nozzle the CLI refused. A
P1S now gets 0.20mm Standard @BBL X1C and 73 filaments instead of 4. An
older sidecar reports nothing here, which leaves the name matcher in
place -- degraded as before, not broken.

Material is now a hard partition in the filament pre-pick rather than a
+10 bonus. A preset stating a different material than the plate asks for
is the wrong preset, not a worse one: wrong nozzle temperature, wrong bed
temperature, wrong flow. A preset stating NO material stays eligible --
unknown is not wrong, and 32 shipped profiles genuinely have none. The
same rule reaches the retain path, which held a slot on
printer-compatibility alone and so cemented a wrong-material pick through
every re-pick. A preset the user chose themselves is exempt: printing
PETG on a plate a designer labelled PLA is a legitimate thing to do, and
this rule exists to correct the auto-pick, not to overrule the user.

Two more, both found while tracing this and neither reported:

Among process presets equally valid for the selected printer, the one
nearest a 0.2mm layer height now wins. Within a tier the list is
alphabetical and Bambu's naming puts the finest height first, so every
slice that did not name its own process silently got 0.08mm Extra Fine on
an X1 Carbon and 0.06mm Fine on an A1 mini -- correct presets, nobody's
default. Ties break toward the coarser, faster height; a name with no
readable height is still pickable when it is the only candidate; a
process the 3MF named still wins outright.

H2DP is aliased to H2D Pro, the same shape as the A1M rename in #1649 --
the bundle spells the model one way in preset names and another in the
printer preset, so an H2D Pro classified all 198 processes as another
printer's. Deliberately narrow: H2DP and a plain H2D are different
machines and must not collapse.

A dropdown the printer filter would empty now shows the unfiltered list
instead. That state was reachable for four printer models and told the
user nothing; a visible preset for the wrong printer can be changed, an
empty dropdown cannot.

Verified against live Orca 2.4.2 and BambuStudio 02.08.02.61 sidecars
over the real 1156- and 1792-profile trees: every one of the eight
printer models tested now auto-picks a 0.20mm process for its own
printer, a PLA plate draws a PLA preset and a PETG plate a PETG one.
Each change was confirmed to fail its tests when reverted.
2026-08-28 10:30:32 +02:00
maziggy 699fc419fe Paint an AMS slot card with the spool's colours, not the tray's (issue #2967)
A Ziro "Colorful Mist" -- yellow, cyan and pink, effect Tri Color --
hovered on the printer card as a single flat pink rectangle. A printer
reports exactly one tray_color hex per tray and nothing else, so
telemetry cannot describe a gradient or a surface effect and never will.

The header now paints the bound spool's own swatch whenever that spool
declares extra colour stops or an effect, through buildFilamentBackground
-- the builder the Inventory swatches already use, so the two surfaces
cannot drift apart. A plain single-colour spool keeps the flat
backgroundColor it has always had, and a slot with nothing bound is
untouched, so the common case goes nowhere near the gradient path.

The gate is "any stop at all", not "more than one". buildColorLayer
ignores rgba the moment stops exist, so a one-stop spool renders that
stop rather than the slot hex; skipping it would leave this card showing
a different colour from the Inventory row for the same spool, which is
the class of disagreement the shared builder exists to prevent.

isLightColor now tests the colour actually on screen. Once the spool's
swatch is painted the base is no longer the slot hex -- a single stop
replaces it outright, and an effect-only spool paints the spool's own
rgba -- so testing the slot hex would pick the text colour for a
background that is not there.

Above one band no single hex can decide legibility, and the name sits
dead centre where a multi-stop background is likeliest to change under
it. So a genuinely multi-band header puts the name on the same scrim the
vendor badge already uses. One stop, or an effect over one colour, still
vendor badge already uses. One stop, or an effect over one colour, still
leaves a real base colour to test and keeps the contrast rule it had.

Spoolman mode gains the gradient in the process. Spoolman has held the
stops in filament.multi_color_hexes all along and the label renderer has
been reading them for releases, but _map_spoolman_spool never returned
them -- so the identical roll registered in Spoolman rendered flat while
the internally-managed one did not. Both now share one parser rather
than reading the same field two ways.

What stays asymmetric is Spoolman's own limitation, and it is pinned by
a test rather than left to be rediscovered: Spoolman has no field for a
surface effect at all. Its only neighbouring field,
multi_color_direction, says how the stops are laid out, not that the
roll is silk or glitter. effect_type is therefore None for a Spoolman
spool instead of guessed at, and silk/sparkle/wood remain internal-only.

The other two halves of the report -- the header naming the colour
"White" instead of "Colorful Mist", and the print dialog offering
"A3: PLA (White)" -- were already fixed on dev by #2875 and by the
slot-naming change that landed the day after this was filed. Neither is
in 1.2.5.3, which is what the reporter is running.
2026-08-28 09:46:37 +02:00
maziggy 4f10d15584 Record the colour a slice is actually printed in (issue #2977)
Every file the internal slicer produced came back with filament_colour
= #00AE42 whatever filament was picked: a green plate thumbnail, green
metadata, and a "Color mismatch" in the Print dialog against the AMS
slot the job had just been correctly mapped to.

A colour is not a property of a filament preset in either slicer. It
belongs to the project, and Bambu Studio and OrcaSlicer set it from the
plate in their GUIs -- so nothing was attached to the preset Bambuddy
sends by name, and the CLI fell back to its own compiled-in default,
which is Bambu green. None of the shipped BBL filament profiles define
filament_colour; the whole tree has zero occurrences.

default_filament_colour is not the answer on its own. Measured against
a 02.08.02.61 sidecar, a profile carrying only that still slices to
filament_colour ["#00AE42"] -- Bambu Studio consumes it in the GUI when
a project is created, not in --load-filaments. So it is read and
rewritten as filament_colour, which the same sidecar does honour: the
reporter's exact triplet returns ["#E8B00C"] when patched this way, and
the colour lands in both project_settings.config and slice_info.config,
which is what the thumbnail and the AMS mapping actually read.

Each filament row in the slice dialog gets a colour control, and the
resolved value flows through a chain: the user's pick, then the preset's
own default_filament_colour, then the colour that slot was designed with
in the source 3MF. A slot with none of the three is left untouched
rather than given a guess.

The designed colour is read from project_settings.config, not
slice_info.config. The latter records what the file was last sliced
with, which for a source that never carried a colour is #00AE42 itself
-- measured -- so using it would have been circular.

The control is offered on single-filament sources too, because an STL,
and equally a mesh-only 3MF exported from CAD, has no colour anywhere
else to inherit. That is the case this exists for, and it took three
shapes to make it visible: a swatch in the label row was identical to
the read-only dot multi-colour rows have carried for releases, and
adding the hex beside it only made it look like a caption. It now sits
beside the dropdown, styled like it and the same height, with the
swatch and hex wrapped in one label bound to the input so a click
anywhere on it opens the picker.

An untouched slot with no designed colour submits an empty string
rather than the control's displayed default. A sent colour outranks the
preset's own, so pinning the placeholder would silently discard the
real colour of an imported OrcaSlicer profile that carries one.

Slicer Pipelines pick up the same chain without carrying a colour of
their own.

Also adds a warning for a defect found while investigating this: a
filament preset whose name the sidecar's bundle cannot resolve is not
rejected. The CLI inherits nothing, falls back to its defaults for
every field, and returns a well-formed success -- measured, an
unresolvable name slices as filament_type ["PLA"] at nozzle_temperature
["200"] with filament_ids [""] and filament_vendor ["(Undefined)"], so
a PETG preset a sidecar image predates prints at PLA temperatures with
no diagnostic anywhere. Both signals are required together, which keeps
it off the two legitimate lookalikes: a hand-written profile that never
named a vendor still carries a real filament id, and a user's own cloud
preset carries a vendor while legitimately having no bundled id. The
file is kept rather than refused, unlike the missing start G-code of
whoever can see the temperatures.
2026-08-28 09:23:30 +02:00
maziggy 5211fd4575 Store a failure reason in one vocabulary, not three (issue #2974)
failure_reason was written three different ways and nothing reconciled
them. derive_failure_reason wrote English display labels ("Layer shift"),
older builds of the archive editor wrote the translated label in whatever
locale that user was running, and the two stale-archive paths wrote
English prose sentences. All three reach one column -- the archive PATCH
has mirrored the field onto the latest print-log entry since #1444 -- and
the Failure Analysis widget groups on the raw value, so one real cause
occupied several buckets. On a live install before this landed:
print_log_entries held 91 rows reading "User cancelled" beside 1 reading
"userCancelled".

In an English UI those two render as the same words twice with different
counts, which is why nobody spotted it. In any other locale one of them
stays English, because a stored label has no key for t() to resolve. The
editor was worse than cosmetic about it: its reverse lookup compared the
stored value against t() in the current locale, so for a non-English user
nothing matched and the dropdown opened empty over an archive that
plainly showed a reason.

The keys were already canonical and already enforced.
_FAILURE_REASON_KEYS in api/routes/print_log.py rejects anything else
with a 400 and explains why in its own comment -- the widget renders
values back through t(), so an unrecognised one surfaces as a raw string.
derive_failure_reason had simply never been held to that rule. It now
produces keys, and the cancel branch returns userCancelled.

The two "Stale - ..." sentences become one new noStatusUpdate key. Both
describe the same observation, that no end-of-print status ever arrived;
which of the two situations occurred is already carried by status --
cancelled at the stale-cleanup site, the reconciled outcome at the
reconnect site -- so collapsing them loses nothing and gives Statistics
one bucket instead of two sentences that could never be translated. It
had to enter the vocabulary rather than merely be tolerated, because the
editor discards any value it does not recognise.

Existing rows are converted by a startup migration folding 168 historical
labels onto the 12 keys across both columns. It is exact rather than a
guess: every label across all 14 locales resolves to exactly one key,
with no collisions. The map is a frozen snapshot rather than something
read from the locale files at run time -- it maps what was written
historically, so regenerating it from the current translations would
silently stop recognising the very rows it exists to convert. A value
outside the map is left alone; guessing would be worse than leaving one
honest string in its own bucket. There is no one-shot settings flag, on
purpose: the statement only matches values in the map and a key is never
a label, so it is self-terminating, and a flag would permanently skip
anyone who restores an older database.

The last part is a data-loss bug that was not in the report. The editor's
fallback to '' was not merely a wrong-looking dropdown -- the empty
selection was then saved over the stored text, so opening the editor on
an archive whose reason was free text and pressing Save destroyed the
classification. An unrecognised value now keeps its own option and
survives a save.
2026-08-28 08:28:27 +02:00
maziggy e5a18bf58b Key a K profile on its nozzle's flow type
A printer files each calibration under a nozzle id of the form HH00-0.4
(high flow) or HS00-0.4 (standard) and can hold both for one diameter -- a
maintainer's H2D carries 102 high-flow entries against 6 standard --
because the same filament reads a different K through each. Nothing read
that, so a standard-flow profile could be selected for a high-flow nozzle
and vice versa.

The flow is now stored with the profile, shown against each option in the
picker, and checked before a stored profile is applied. Two spellings have
to agree for that: a calibration entry says HH00-0.4 while the fitted
nozzle reports HH01, so the comparison is two characters rather than four
-- the trailing digits are a hardware variant the calibration table
normalises to 00.

Unknown flow on either side matches anything, which is what it has to do.
Every profile stored before this has none. And an X1C declares none on any
profile at all -- probed live, all eight come back with an empty nozzle id,
against a four-digit cali_idx and a populated setting_id -- even though the
machine really does take either nozzle. supports_nozzle_flow_type is
therefore the wrong thing to gate on: it returns True for an X1C, and
treating that silence as Standard would have dropped every X1C profile the
moment a high-flow nozzle was fitted. What the printer's own table declares
per profile is the test.

NozzleInfo.nozzle_type carries two vocabularies by printer generation --
the nozzle material on legacy printers, the flow code on H2 -- and the
comment claiming only the former is corrected. Anything that is not HH or
HS reads as unknown, which is what makes the material spelling harmless.

Storing both flows for one hotend and diameter is deliberately not done:
spoolman_k_profile is UNIQUE on (spool, printer, extruder, diameter) with
no flow column, and allowing a second row in internal mode alone would
break inventory-mode parity. The picker marks a profile whose flow does not
match what is fitted instead of letting it look configured while doing
nothing.
2026-08-27 13:42:55 +02:00
maziggy a7b563334e Configure a spool's filament preset and K profile per nozzle
A slicer preset is bound to a printer model: "Bambu PLA Basic @BBL X1C" is
not the same preset as "@BBL H2C", and Bambu names a nozzle size in it as
well. A spool carried exactly one, which was right until the same spool was
used on a second machine -- the AMS slot on the other one was then
configured with a preset that machine has no profile for. K profiles had
the matching gap from the other side: the tables have always been keyed per
hotend, but the picker could not express it.

spool_filament_preset and its Spoolman twin store the exceptions, keyed
(spool, printer_model, nozzle_diameter). Model rather than printer because
the preset is a property of the model -- "@BBL X1C" is the same preset on
every X1C, and asking per machine would mean picking the identical value
twice. K profiles stay on printer_id, because a K value is measured on one
physical hotend and two machines of the same model legitimately differ.
Resolution is exact (model, diameter) -> (model, "") -> the spool's own
preset, so a spool nobody has configured behaves exactly as it did before.
The form writes one row per nozzle size and never the "" row; that level is
kept for API clients wanting one value to cover a model.

Both halves cover every standard nozzle size rather than the size currently
fitted, because a spool is configured once and nozzles get swapped. The PA
Profile tab becomes a Printers tab: a model list beside a detail pane
holding a preset row per size and a K-profile grid of size by hotend. Each
model is offered only the presets that name it, through the same matcher
the Configure AMS Slot modal filters with, which moves out of that
component into utils/slicerPrinterMatch. Presets whose name identifies no
model -- most user-authored and OrcaSlicer ones -- stay offered everywhere,
as does whatever is already selected, so a saved override cannot vanish
from the control that shows it. Every preset carries an origin badge in the
wording and colours that modal already uses.

Every path that configures a slot now respects both: manual assign in
either inventory mode, RFID auto-assign, the Spoolman tag link, the re-fire
when a slot goes empty to loaded, the re-apply after a calibration-table
refresh, and the re-selection when a Filament Track Switch moves an AMS to
the other nozzle. Which nozzle a slot feeds, and how wide it is, was worked
out independently in seven of those places, each reading nozzles[0] for
every slot on the machine -- correct on a single-nozzle printer and on a
dual-nozzle printer with matching nozzles, wrong the moment two sizes are
fitted. That resolution is now services/slot_nozzle.

Which array entry belongs to which hotend is no longer inferred. Measured
on an H2D fitted with a 0.4 high flow on the left and a 0.6 on the right,
nozzles[0] reads the right hotend, so the array is indexed by extruder id
and the H2/X2 parser's convention is the one that holds. The legacy
parser's opposite convention never governs a real dual-nozzle machine:
every model in DUAL_NOZZLE_MODELS reports device.nozzle.info, and
left_nozzle_diameter appears in no log or wire capture. Two comments that
said otherwise were wrong and are fixed; amsHelpers' code was right all
along and only its comment lied.

Four defects surfaced while wiring it, all pre-existing except the last.
The picker identified a chosen calibration by cali_idx alone, and the
printer numbers its calibration table per nozzle -- on a dual-nozzle
machine the same index exists on both hotends meaning different things, so
saving could persist the other hotend's K value and diameter; SpoolBuddy's
write-tag page carried a verbatim copy and gets the same fix. RFID
auto-assign chose a K profile with no extruder test at all, so a spool
calibrated on both hotends had a coin toss decide which pressure-advance
value the slot got, on the path that runs unattended every time a Bambu
spool is loaded. The Spoolman tag-link path resolved no preset whatsoever,
configuring every linked slot with a generic material id and discarding a
preset set in inventory -- the same defect #1713 fixed on the assign path,
one function over. And an FTS inlet move re-selected K for nozzle 0 rather
than for the nozzle the AMS had just been moved to.

The last one is new here: a per-model override can be a cloud USER preset,
whose PFUS-prefixed id the slicer rejects, and passing it straight into
extrusion_cali_sel would silently lose the K-profile link. Reached the
printer only where such an override exists, which is why nothing in the
suite caught it. printer_safe_filament_id falls through to the spool's own
preset and then the tray's RFID value instead.

Reading a printer's calibration table asks for one nozzle size at a time.
H2-series firmware answers only the first one or two of a concurrent burst
of extrusion_cali_get and silently drops the rest, each dropped request
costing a five-second timeout before its retry: measured at 11 and 23
seconds on an H2C and an H2D for four parallel requests, against roughly
one second in series. An X1C answers all four at once, which is why this
only ever surfaced on dual-diameter printers. Printers themselves are read
in parallel -- separate machines are separate connections.

The Configure AMS Slot dialog opens on the spool's own configured values,
falling back to the slot's last manual configuration and then the tray's
RFID data. The spool form is wider for the two-pane layout, colour, weight,
cost and location move to their own tab in two columns, and a printer card
in expanded view lists every fitted nozzle size rather than the first entry
alone.
2026-08-27 13:03:15 +02:00
maziggy 9500c046c0 Ask which nozzle to feed when a Filament Track Switch is fitted
Load and Unload in the AMS slot menu did nothing on an H2C with the switch
fitted. The ams_change_filament command carries an optional extruder_id and
Bambuddy never sent it. That is correct on every printer without the switch,
and is what BambuStudio does there too -- each AMS is wired to one hotend, so
the firmware works the target out for itself and an explicit value would only
be a guess at something it already knows. Fit the switch and every AMS is
bound to one of its two inlets instead, either hotend is reachable from any
slot, and a command naming neither leaves the firmware nothing to act on. It
was discarded in silence.

Load now asks which hotend to feed, on the same terms as Bambu Studio: no
preselection, so a stray Enter cannot feed the wrong one, and the hotend
already fed from that very slot greyed out. Printers without a switch send a
byte-identical command and still load in one click. A switch fitted but not
yet set up -- any AMS still unassigned to an inlet -- refuses the load up
front rather than publishing one the firmware will drop, mirroring
DevFilaSwitch::IsReady, which likewise demands a switcher position on every
AMS.

Unload was addressed at the same time. It was aimed with tray_now, a single
value for the whole printer, so on any dual-nozzle machine with both hotends
loaded it unloaded whichever that field happened to name regardless of which
slot's menu was used. It now names the slot and resolves the holding hotend
from device.extruder.info, previously read for temperatures only. That
resolution is gated on the printer having reported two extruders:
single-nozzle machines do send the block, but nobody has read a single-nozzle
snow value off the wire, and staking every X1C, P1S and A1 unload on an
unverified encoding buys nothing where tray_now is already unambiguous.

Both new state fields ride the WebSocket and are in the broadcast key, and
both are computed in the REST status route as well -- that response is what
the page has before any push arrives, and leaving them at their defaults
would have told a correctly set-up machine that its switch was not set up.

Verified on H2C-1, AMS-A slot 3: loaded and unloaded from each hotend in
turn, all four correct. Covered by 18 MQTT unit tests, 4 status-dict tests,
7 integration tests and 6 component tests.

Two known stragglers, both deliberately left alone. Load on an AMS-HT slot
has never worked -- an HT unit is addressed by its unit id rather than
ams*4+slot, which these endpoints do not accept -- so unload there keeps the
printer-wide form it always used instead of gaining a slot it cannot name.
And a slot's K-profile still follows the AMS's plumbing rather than the
nozzle just loaded, so loading to the far hotend leaves the other one's
calibration bound; that is the same per-nozzle problem the filament and
K-profile redesign is scoped to fix.
2026-08-26 12:04:42 +02:00
maziggy 196fcaf15b Keep the last run a batch order can re-queue a plate from
An order produces what it still owes by cloning an existing queue item for
the same plate. That row is the only record of the printer target, AMS
mapping and print options the user chose, so deleting the last one left the
order reporting work outstanding that nothing could produce, and no way to
close it out: Cancel was hidden unless there were pending items to cancel,
which by then there were none.

Deleting an order's last surviving run for a plate now cancels it instead.
A cancelled run does not satisfy a target, so the order still owes the print
and can still make it. A completed run is exempt and still deletes outright
-- rewriting it as cancelled would falsify what the order produced.

Also: the response reports per plate whether anything is left to clone, so
the card explains a stranded plate rather than offering a button that can
only fail; dispatch skips a stranded plate instead of aborting the whole
order; and Cancel is offered for any active order.
2026-08-26 10:11:55 +02:00
Whitigol a68933cb2f Allow Avery label sheets to start at an unused position (#2879) (#2918) 2026-08-26 08:57:44 +02:00
maziggy 14f46510ba File the printer's calibration table under the nozzle it belongs to (issue #2854)
The K value on an AMS slot card went blank after a while and came back after a
backend restart. It is not the MQTT merge: that preserves a tray's k correctly.
H2-series trays have no k to preserve. Verified against the H2 wire capture in
logs/vp_wire -- every tray reports cali_idx and nothing else -- so the number on
the card is resolved from that index against the printer's calibration table in
state.kprofiles, and that table was a single global list.

An extrusion_cali_get response is the complete table for one nozzle diameter,
and the printer answers whoever asks; BambuStudio's queries arrive on the same
report topic we subscribe to. Every response was assigned straight to
state.kprofiles, so any one answer stood for the whole printer. The nightly
GitHub backup asks for 0.2, 0.4, 0.6 and 0.8 in turn and finishes on 0.8, which
holds nothing on a 0.4+0.6 machine: logs/bambuddy.log records exactly that at
17:15 on 2026-08-25, and the table was empty from then until something refilled
it. Responses are now bucketed by the diameter they describe, so an empty answer
for a size the printer does not have clears only that size. The three assign
paths that look an index up by nozzle_diameter get the same fix for free -- they
were quietly finding nothing whenever the last response was for another nozzle,
which is what spoolman_inventory has been logging as a stale kp.

Bucketing makes state.kprofiles a union, and cali_idx is numbered per nozzle, so
the index alone no longer identifies a profile. The REST serializer has keyed on
(extruder, cali_idx) since c5e005586; the WebSocket one still keyed on the index
alone, which meant the first render of a card could be right and every update
after it wrong. Both now share one resolver. It goes through the extruder the
slot feeds, and where that does not single out one profile -- a single-nozzle
printer that has been swapped, so both its tables sit under extruder 0 -- it
falls back to which diameters are actually fitted. Where neither settles it the
card shows nothing, because a blank space is a smaller error than confidently
printing the other nozzle's number. Deliberately no loosening to a bare cali_idx
lookup on a miss: that is the cross-nozzle bleed the extruder keying was added
to stop.

Nothing read the table on connect, which is the other half of the report. It
arrived by luck -- a visit to Profiles or Configure Slot, a backup, or the
printer answering someone else -- so a Bambuddy nobody had opened showed a card
with no K values at all, and "restart and they come back" was the printer
happening to broadcast rather than anything we did. It is now read once per
connection, on the same latch the stale-print reconcile uses. Only the fitted
diameters are asked for, one request on a single-nozzle printer and two on a
dual; probing the four sizes blind is what the backup does and what blanked the
table. The edge is gated on a nozzle diameter being known as well as on the
state being known, because the first push_status is what makes the state known
and does not always carry the nozzle fields -- latching there would spend the
connection's one attempt on a printer that could not yet say what was fitted.

Adopting an unsolicited table now logs at debug. It was the quietest way for the
card to change underneath us and there was no way to see it in a support bundle.

Three test files gained nozzles=[] on their PrinterState stubs. The field has
always been on the dataclass; the connect edge is simply the first thing on that
path to read it.
2026-08-25 17:52:02 +02:00
maziggy 08a58b1ef4 Keep both modes' slot assignments across an inventory mode switch (issue #2812)
Turning Spoolman mode on ran an unfiltered delete(SpoolAssignment) across every
printer. Turning it straight back off cleared the other table instead, so the
two directions were symmetric in code and one-way in effect, and the setting
auto-saves on a 500 ms debounce with no save button and no confirmation.
Opening the settings page to see what the option did was enough to destroy the
configuration: the reporter's log shows four toggles in 85 seconds, which is
someone looking and reverting, and the assignments never came back.

The deletion was not careless. Checks that read both assignment tables would
otherwise let a row in the mode you are not using answer for the mode you are,
which is how #1473 was fixed, and emptying the inactive table made that
impossible by construction. The cost was that the guarantee was bought with the
user's data. That decision belongs to the readers -- the mode is a property of
the install, not of the rows -- so spoolman_owns_assignments now answers it and
nothing is deleted on a toggle. Each mode keeps its own assignments and
switching is reversible. Existing installs need no migration: their inactive
table is already empty, because it was being emptied.

Six sites had to be told which mode they meant, and only two of them are the
reads you would guess at, the missing-assignment notification and the queue cost
estimate. The per-slot K-profile lookup consults the built-in table first and,
on a hit with no matching profile, deliberately stops rather than falling
through to Spoolman, so a leftover row would have shadowed the Spoolman binding
for that slot -- the symptom #1556 reported from the other direction.
configure_ams_slot *writes* a K-profile against whichever table answers first,
so the same leftover would have filed a calibration against a spool the printer
is not drawing on and never written the local one, leaving a calibration that
appeared to succeed and then did not apply.

The auto-unlink pass in on_ams_change is the one that would have made this
change worthless. It drops any assignment whose tray no longer matches the
fingerprint it recorded, and it ends in db.delete. Ungated, it would have
removed the preserved rows one slot at a time as the AMS contents changed under
the other mode -- the same loss, arriving slowly enough not to be connected to
the toggle that caused it.

The sixth is the built-in remaining-weight fallback inside the Spoolman AMS
sync, and it is deliberately left inert rather than woken up. It could never
fire while the table it reads was being emptied, it is keyed by slot rather than
by spool, and create_spool writes remaining_weight unconditionally where the
update path does not -- so preserving the rows would have seeded a stale figure
into a brand new Spoolman spool the first time a tray reported an unusable
remain%. The query stays, gated off, so the intent survives for whoever
revisits the cross-mode fallback.

Separately, a print that could not debit a spool said nothing about it, and that
is what turned a mis-click into lost filament. The reporter's print was already
running when they toggled. At completion it resolved its 3MF, read its
per-filament grams, resolved its tray, and then skipped the debit because the
assignment row no longer existed -- logged at INFO, invisible under the default
log level, while the completion notification fired as usual. 65.49 g was never
deducted and they only noticed because a spool's remaining weight looked wrong.
_resolve_spool_id_for_tray has no tag or fingerprint fallback, so there was
nothing else to catch it.

The skip is now a warning naming the grams, and a completed print that failed to
charge a tray it drew from raises the missing-spool-assignment notification. The
print-start check cannot cover this and was right to stay quiet: the assignments
existed when it ran. The two are different statements -- the first says the
weight may not be tracked, the second says it was not -- so a print warned at
start will notify twice, which is the right trade. Collected across the print
rather than fired per slot, and given the caller's session, because this runs
inside on_print_complete's transaction and opening a second one to read the
printer's name would deadlock against it on SQLite. This is independent of the
toggle and catches any other cause of an assignment disappearing mid-print.
2026-08-25 17:17:26 +02:00
maziggy 39835437a3 Price a print from the spool that fed it, not the default rate (issue #2591)
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.
2026-08-25 16:45:50 +02:00
Kouki Ojima c3677865b6 Give the AMS temperature alarm its own threshold (issue #2905) (#2943) 2026-08-25 15:03:34 +02:00
Kouki Ojima 73912d4f05 Let a clear spool stay clear on the way to Spoolman (issue #2912) (#2924) 2026-08-25 13:45:57 +02:00
MagicMelody84 54af3146a3 [Feature]: Bind Home Assistant sensors to storage locations (dryboxes/bins) (#2827) 2026-08-25 12:17:23 +02:00
maziggy 5dd7bd213f Read a print's destination from the report topic, not just the request one (issue #1820)
current_project_url was assigned in exactly one place, _handle_request_message,
and _on_message calls that only for the request topic. A print started from the
printer's own screen publishes nothing there, so the field stayed None for the
one case the storage verdict exists for: the file is already in the printer's
model library under /userdata/model/history/, which port 990 does not serve.

The verdict then fell through to the sdcard flag, and @ojimpo's H2S reports that
flag true -- its "card" is the internal eMMC -- so every such print ran the full
sweep before giving up. He measured one: 16 filename-and-directory attempts over
22 FTPS connections, 18 of them refused, 6.4 seconds, then a fallback archive
holding a name and nothing else.

The printer does announce where the file lives, as an unsolicited project_file
*response* on the report topic about two seconds before gcode_state reaches
PREPARE. _process_message now reads the url off it, gated on result SUCCESS and
a non-empty value so a refused dispatch cannot name a file that was never
written. Reading it there rather than only at the request topic also covers an
install neither of us had in view: some brokers refuse the request-topic
subscription, and on those no print of any kind had ever populated the field.

The new branch captures state and nothing else. The "External project_file
payload" diagnostic stays with the request-topic handler: our own dispatch is
echoed on both topics, the request-topic echo lands first and clears
_own_project_file_key, so reusing the diagnostic here would have logged every
Bambuddy-started print as somebody else's. A test pins that.

What the print names is now what gets tried -- the five directories a copy could
be in, rather than the ~110 connections that cannot succeed. The probe is still
worth running: an H2S keeps recently used jobs under /cache and archives them in
full while they last, which is why the reporter's two prints on the same day
behaved differently. Slicer-sent prints are unchanged.

The banner no longer describes a step that never happened. With no reason
recorded, a blank archive fell back to the original wording -- "Store sent files
on external storage" is off in your slicer -- which on that printer is on, and
which the internal-storage wording from #2780 already explains would not help on
an H2. The archive that most needed that explanation was the only one that could
not be given it.

So file:///userdata/ now earns its own reason, internal_history, separate from
the brtc://emmc dispatch case. A dispatch chose internal storage and can be
aimed elsewhere; a print of a file that was already there had no dispatch at
all, and telling that operator to pick External in Send names a dialog they
never opened. The banner and the connection diagnostic both read the verdict's
reason rather than a fixed one, so the two surfaces cannot give the same printer
different advice. Thirteen locales, and a wiki section the banner links to.

-----

Read the K-profile selection when the mutation runs, not when it is captured

Configure Slot sends cali_idx from selectedKProfile, and the mutation read it
through its own closure. React Query hands a mutation its options from an
effect, so a click landing between a commit and that effect flushing runs the
previous render's mutationFn -- one that captured the selection as it was before
the K-profile query resolved. The payload then carries cali_idx -1 and the
printer binds the default 0.020 instead of the calibrated K, while the dialog
shows the right profile selected throughout.

It surfaced as an intermittent failure of the per-nozzle K-profile test, about
one full-suite run in six. Reproducing it with staggered query resolution showed
the divergence directly: the select element held the correct profile immediately
before and after the click, and the payload still carried -1. That test's slot is
the most exposed case in the file -- a right-hotend slot carrying the left
hotend's index, where the "keep showing the active profile" safety net cannot
repair an empty recompute.

The selection now goes through a ref written during render, so the mutation
resolves it at execute time. An effect would have inherited the same flush
ordering this exists to escape. The K value and the profile's ids travel in the
same payload and had the same exposure, so they move with it.

Measured over a staggered-resolution grid: 2 failures in 15 runs before, 0 in 12
after. api.getSlicerPrinterModels was also missing from the test file's mock, so
that query ran with no query function and rejected in all 37 tests -- mocked now,
though on its own it changed nothing, which is how the ref was confirmed as the
fix rather than assumed.
2026-08-25 11:19:46 +02:00
maziggy 4e79f9c2f7 Fill in a fallback archive when the 3MF finally arrives (issue #2957)
A failed TLS handshake pauses a printer's file service for five minutes, and
the archive flow checks that pause at the top of its path loop and gives up
before opening a connection. A print that starts inside one gets an empty
fallback archive 13 milliseconds later, having never touched the network.

Four minutes on, the pause clears and the cover endpoint downloads the same
file, parses it, takes a thumbnail out of it, and publishes it to the shared
3MF cache under the exact key the archive flow looks up. Nothing ever looks:
all three readers of that cache run before or during the print-start handler
that already gave up, and print completion drops the cache as its first act,
deleting the file. No path existed by which a fallback archive could become a
real one.

Offer a later 3MF to the running print's archive, filling the existing row
rather than adding a second -- the row id carries the energy reading, the
timelapse session and the start notification. That covers the reported case at
no network cost, since opening the printer card already downloads the file.

Schedule a bounded retry when the pause is what caused the fallback, spending
the cache first and the printer only if that misses. Not scheduled for the
other cause: a print kept on internal eMMC has no FTPS copy to come back for,
and retrying it is the sweep removed in #2780. The two reasons are now recorded
separately instead of both landing as "no 3MF".

Recovery refuses anything that is not a readable 3MF -- a truncated download
would replace an honest empty archive with wrong metadata -- and leaves an
archive alone once it has a real file.

Three things the recovery path has to get right, each with a test:

Reduce every retry candidate to a bare name. MQTT hands `filename` over as
"/data/Metadata/plate_1.gcode" on some firmware, and joining that onto the temp
directory yields the absolute path itself, so the retry is fed the flow's own
sanitised candidate list and strips the path again on its own account.

Serialise recovery per printer. The cover endpoint's single-flight coalesces by
view, so two views race each other, and the retry task and print completion can
land on top of either -- each reads file_path == "" and runs a full copy,
leaving the row on one timestamped directory and the rest orphaned.

Keep looking for photos in the pre-recovery directory. `archive_dir` derives
from `file_path`, so filling the row moves the archive's directory, and a photo
uploaded to the empty card while the print ran stays where it was put.
2026-08-25 09:33:52 +02:00
maziggy 06e5114a03 Do not report a printer as Safe when nothing is checking it (issue #2952)
The printer card's AI badge collapsed every class that was not Warning or
Failure into green Safe, and the service reported `safe` whenever it had no
verdict. The state entry is created when a monitored print is first seen --
before the first snapshot, let alone the first inference -- so a rejected ML
API token, an unreachable ML API, a failed capture and an unset External URL
all rendered as a healthy watched print: green Safe at score 0.000.

For a safety feature that is the worst failure mode available: it asserts the
print is being watched exactly when it is not. The reporter read that badge and
concluded the loop had never started. It had been calling the ML API every ten
seconds and being turned away with a 401 -- invisible because Obico's auth
layer rejects a bad token before its request log sees it, and because
successful checks log nothing there either.

Add two honest states. Not checking (amber) when the last poll produced no
result, carrying the reason; Starting while a monitored print waits for its
first result. Score and frame count are withheld while not checking, since
0.000 beside "Not checking" reads as a measurement rather than its absence.
The reason is per printer, so a card names its own problem rather than
whichever printer failed most recently, and stays behind settings:read because
it can quote configured URLs -- the badge state does not, because whether a
print is watched is not configuration. An unrecognised class now falls back to
Starting, not Safe.

Test Connection saves the form before probing, so a green result describes the
configuration the loop actually runs with rather than what is typed in the
boxes.
2026-08-25 08:49:24 +02:00
maziggy b1f5ec9642 Let one checkbox say where a slice's settings come from (issue #2942)
Two features in the slice dialog read as one. "Use the file's built-in
settings" slices a 3MF the way its designer set it up, ignoring the picked
profiles. The per-option "from file" ticks beside each setting carry the
designer's individual deviations onto the profile you picked, and those
arrived pre-ticked whatever the checkbox said. So a slice run deliberately
without the file's settings still took sixteen values out of it -- the
reporter's log names them, enable_support and support_type among them,
landing on a process preset they had chosen on purpose.

The ticks now follow the checkbox. Off, nothing comes out of the file until
it is asked for by name; on, every setting the file changed shows ticked,
because on that path the file really does drive the whole slice. Taking the
designer's work in bulk is still one click, from a line at the top of the
panel that says how many settings the file changed -- it is the only way
left to reach them without hunting for chips across six pages of 348
options -- and it still leaves the machine-tuned keys and the two that are
the picked preset for a per-key decision.

The panel greys out options the slicer's own rules switch off, and it was
evaluating those rules against what the user had typed alone, falling back
to the compiled-in schema defaults for the rest. A preset with supports on
therefore read as enable_support: false and greyed out the whole Support
page while the slice ran supports. A greyed row greyed its tick too, which
is how the reporter's screenshot shows a support type marked "from file",
applied to the slice, and impossible to clear. The rules now see what the
slice will actually run with: the preset's values, the file's values for
the keys that are on, and anything typed on top. The tick is no longer
gated on those rules at all -- it answers a different question, not whether
an option is in play but where its value comes from.

Underneath both, the support carry-over ran outside the ticks entirely,
lifting four keys out of any 3MF that had supports on with nothing on
screen able to decline. It now stands down for the keys that were offered
and turned down, which the request can say for the first time: an empty
design_overrides list means the caller was shown the file's settings and
took none, where no list at all is a caller that predates the choice. That
distinction is what keeps the carry whole for sources with no deviations to
tick, an OrcaSlicer export among them, rather than trading one silent
default for another.

Worth knowing: a Bambu Studio file with supports enabled no longer switches
supports on for you. Tick Enable support, or the checkbox above the panel.
Measured against the reporter's own sixteen keys, and covered by backend
and frontend tests -- reverting any one of the three changes fails tests.
2026-08-24 11:04:33 +02:00
maziggy 6988a30eae Carry a fault's description in the status response (issue #2926)
The HMS catalogue has been in the backend all along and the status
response never carried it, so every consumer that wanted to tell a user
why a print halted resolved the same 853 codes from its own duplicate of
the same sentences -- this repo's Python table, the frontend modal's, and
at least one third-party client whose catalogue exists purely because the
server would not say. Each ages separately, and a relay watching a
printer could only manage "your printer needs attention" while the server
already knew it was "Filament ran out. Please load new filament."

hms_errors[] entries now carry a description, defaulting to null so a
client that has never seen the field is unaffected.

It is resolved where the fault is parsed rather than at the boundary that
prompted the request, because there are three serializers of a fault, not
one: the status response, the WebSocket broadcast, and the completion
payload the queue's failure reason is built from. Adding it to only the
first would have handed half the feature to a relay watching the stream,
which is the likelier consumer of the three. The queue's failure reason
now quotes the resolved sentence instead of looking the code up a fourth
time, and the notification path reads it rather than re-deriving. That
they cannot report different text for one fault is the point, and a test
asserts they agree.

describe_fault is the single mapping from either code shape onto the
table. An 8-char print_error is the catalogue's MMMM_EEEE key with the
separator removed -- the parser derives full_code and that key from the
same 32-bit value -- so it resolves exactly. A 16-char hms[] identifier
is tried whole and then collapsed to its first and last groups.

That collapse is lossy, and keeping it was the decision worth making
carefully. #2728 counts 65 documented faults falling onto 0300_0001
alone, so a hit can attribute a neighbour's sentence to this fault, and
refusing it looks like the stricter reading. It is not: the notification
path, the queue's failure-reason helper and the frontend modal have all
resolved hms[] faults this way for as long as they have existed, and it
resolves real ones -- a 0500_4038 nozzle mismatch arrives in that shape.
Declining to collapse would have stopped describing faults that are
described today, silently suppressed the notifications they raise, and
left this field null while the UI showed text for the same fault.
Narrowing it belongs with #2728, where both key spaces can move together.

So the lookup is exactly what it was, verified rather than asserted:
a test walks every catalogue code in both fault shapes across all three
alert levels and checks the result against the derivation this replaces.
A future change to the lookup cannot quietly stop notifications firing.

The catalogue ships one language, so the field is English and
unlocalized, which the schema and the API reference both say next to it.
The camwall feed is deliberately left alone -- it is code-only because
its token travels in a URL on a screen, and a readable sentence discloses
more than the camera picture already does. The frontend keeps resolving
its own text: switching it would change what filterKnownHMSErrors counts
across eight call sites, which is #1840 and #2728's argument to have.

HMSError.message goes with this -- a text field that was never set or
read anywhere, and an invitation to populate the wrong one now that a
live description sits beside it.

-----

Record a failure code the user can actually look up

The queue's failure reason formats a fault's module and error into
MMMM_EEEE, and that one derivation never masked the error to 16 bits. A
fault arriving from the printer's hms[] array carries its alert level in
the code's high half, so the label came out as 0500_24038 -- five digits
in a group that has four. It is not a code anyone can find on Bambu's HMS
index, and because it matches no catalogue key the sentence explaining
the failure was dropped along with it, leaving the bare number alone.

The nozzle-size mismatch behind #1111 is exactly such a fault. Reported
one way it read "[0500_4038] The nozzle diameter in sliced file is not
consistent with the current nozzle setting"; reported the other, the same
physical fault read "[0500_24038]" and nothing else.

There is already a helper that gets this right, used by the archive's own
failure-reason lookup, so this calls it instead of keeping a fourth copy
of the derivation. It also takes the raw integer code the MQTT payload
carries, which the local version only handled as a string.
2026-08-24 09:47:39 +02:00
maziggy 7b181b84f0 Let a filled or foamed filament keep its own name (issue #2902)
The reduction that gave an AMS slot a material type read PLA-AERO,
PLA-GF, ASA-GF and PPS-GF as their base material, so a slot loaded with
foaming or glass-filled filament went out saying plain PLA or ASA. That
is worse than the bug it replaced. "PLA-AERO" matched nothing before,
which was useless but honest; "PLA" matches every PLA plate in the
queue, so the dispatcher would have sent one to filament that will not
print it -- and the contract the first fix claimed, that it could only
ever repair a slot, no longer held. @doncaruana caught PLA Aero on the
issue.

All four are values Bambuddy itself offers: filament_fields.json is the
material list the Profiles editor puts in a dropdown, and the reduction
table was assembled from the cloud filament names and the frontend
preset parser without ever being checked against it. It is checked now,
so the next type added to one and not the other fails a test rather than
a print. ASA-AERO joins them from the cloud catalogue (GFB02).

The table hyphenates because the slicers do, while a spool says "PLA
Aero" and every Bambu preset name says "Bambu PLA Aero". Adjacent words
are joined and taken when the join is a type exactly -- exactly, because
letting the prefix and suffix rules reach across a space would make
"Support for PLA" a type by its tail.

Also from @doncaruana, and the better half of his point: a preset is
chosen from a list the slicer defines, so it already knows its own type
and nothing has to be read out of a product name. The resolver now hands
that answer back and both assign routes prefer it. It cannot be the only
source -- material is required on a spool and slicer_filament is not, and
the spool this issue was reported for had no preset at all -- so the
reduction stays as the fallback for spools without one.

Two things had to move with it. The auto-unlink guard compared the
slot's reported type against the reduced material, so a spool whose
preset outranked its material column would have been unlinked from the
slot it had just been assigned to; it now accepts any type the assign
path could have written. And two lookups keyed by material took the
catch-all for a type they had no row for, which sent an ASA-GF spool out
at 200/240 -- too cold to extrude -- and preheated its chamber to
nothing. Both fall back to the base material last, so PLA-CF, PETG-CF
and PA-CF keep the rows they are listed with, and ASA-CF and ABS-GF pick
up ranges they had been missing all along.

What counts as a material name is decided by the base for the same
reason: saying yes throws the value away and rescues the slot from the
generic-material fallback, so the answer has to be no when that fallback
has nothing to offer. ABS-GF reduces to a generic ABS the printer can
resolve; PPS-CF reduces to nothing and is left as it stands. Adding a
type to the table therefore cannot quietly change that answer, which is
how these five slipped through in the first place.

------

Hand the bundled chamber-preheat table back the way it is read

Every lookup of the per-filament chamber map happens after the keys are
upper-cased, and the parser documents exactly that: keys uppercased,
DEFAULT always present so the resolution loop can index it
unconditionally. The three fallback paths returned the bundled constant
as declared, with the lowercase "default" row the Settings editor writes
and displays, so an install that had never opened the setting got a dict
the loop could not read its fallback out of and used a hardcoded 0 for
any filament without a row of its own.

It reported the right number only because that bundled default is 0.
Raising it would have changed nothing for everyone who had not
customised the map, with the map in Settings still showing the value
that was not being used.

The test that should have caught this asserted the fallback under either
spelling, and its docstring contradicted itself between title and
comment. It pins the contract now, over all four ways the parser can
fall back.
2026-08-23 10:36:50 +02:00
Sean K 55cc64c87d Add printer video downloads and range selection (#2853) 2026-08-22 13:22:56 +02:00
sgiffhorn 937440f956 Report the selected plate on the archives API (#2796) (#2871) 2026-08-22 12:44:47 +02:00
maziggy 4e40a5022c Register a Spoolman extra field with its own write (issue #2903)
Spoolman rejects a spool whose extra dict carries a key it has not been
told about, answering 400 "Unknown extra field tag.". Bambuddy keeps the
tray UUID in extra.tag, so that key has to exist before the first spool
is created. Registration ran from three hand-maintained lists that fire
when the integration is set up -- the connect route, startup, and two
inline blocks in the inventory routes. Enabling Spoolman from the
Settings page reaches none of them, so the first AMS sync on a fresh
Spoolman failed on every slot while vendor and filament creation
succeeded.

Neither fix suggested on the issue is quite the right shape. Adding the
block to PUT /settings/spoolman fixes this path and makes a third copy
of a list that has already drifted -- it would still omit
bambu_color_name. Ensuring at the sync entry point leaves the other four
tag writers alone: linking and unlinking a tag, and both inventory edit
paths.

So the registration moved to the write. create_spool, update_spool and
update_spool_full each register the keys of the extra dict they are
about to send, once per client, before sending. Every tag writer funnels
through one of the three, merge_spool_extra included. This closes the
class rather than the instance: a write that carries a key is a write
that registers it, and bambu_color_name shows why that matters -- it
never made it into the connect or startup lists at all, and works today
only because two call sites remembered it by hand.

Best-effort, deliberately. ensure_extra_field already logs and returns
False rather than raising, so a registration that fails leaves the write
to be attempted and to report exactly what it reported before. Failures
are not memoised either, so a Spoolman that was merely restarting gets
another try on the next write.

The older blocks stay. They are redundant now, but the inventory routes'
inline calls are pinned by tests that assert them against a mocked
client, where the funnel cannot run.

The status endpoint is the other half. The Connect button would have
registered the fields, and the reason nobody reaches it is that
GET /spoolman/status reported "connected" whenever an earlier request
had left a client object behind. Roughly twenty call sites build one
lazily, and saving the Settings page builds one as a side effect of
syncing locations, so the flag turned on which page had been opened
rather than on anything about Spoolman. The UI reads it twice -- Connect
only while disconnected, the sync section only while connected -- so
those two controls landed in states the user cannot explain. It now asks
the Spoolman that is configured, including the stale-URL check every
other route already does, and does not probe at all when the integration
is switched off, which used to let a leftover client report a disabled
Spoolman as connected.

That leaves nothing for Disconnect to do, so it is gone. Spoolman is a
stateless HTTP API with no session to close; the button dropped the
client object, the next request rebuilt it lazily, and the status
flipped back on its own within the 30s poll -- an action that looked
like it worked and then quietly undid itself. The enable toggle owns
turning the integration off. Connect stays as what it always was in
practice, a way to re-check a Spoolman that is not answering, and is
shown only then. Its translation keys are left in place; only the
control is removed.

Resolving the client there means the status poll can now fail in ways a
read-only check could not, so it no longer reports failure by failing.
Replacing a client closes the previous one and httpx's aclose() is not
guaranteed not to raise; a poll that runs every 30 seconds answering 500
is worse than one answering what is true either way, which is that
Spoolman could not be reached. The SSRF rejection keeps its own message
rather than folding into the general one -- a URL the guard refuses is
the admin's to correct, and that is only actionable if the log says so.

Tests drive the real client against a fake Spoolman that enforces the
unknown-extra-field rule rather than mocking it away, so each one fails
against the old code for the reason the reporter's install did. The
concurrency test needed the fake to be async: MockTransport answers
without suspending, so the first version ran each request to completion
in turn and passed just as happily with the lock removed.
2026-08-22 12:26:18 +02:00
maziggy 88e8ca81c3 Give an AMS slot a material type, not a product name (issue #2902)
Assigning a spool wrote its material straight into the slot's tray_type.
A slot that says "PLA+" satisfies nothing that asks for PLA: not
OrcaSlicer, not Bambu Studio, and not Bambuddy's own dispatch matcher,
which compares the type the printer reports to the one the 3MF declares
as plain equality. The reporter's slot was unusable for every PLA plate
he had.

Not only a label, either. The same string went into the generic-filament
lookup, which missed, so the slot went out with no tray_info_idx at all
-- the half-configured state #2604 documents the printer as reverting
from -- and took the 200/240 catch-all nozzle range instead of PLA's
190/230.

PLA+ is not a special case. Bambuddy's own colour catalogue supplies the
material dropdown, and around forty of its values are vendor product
lines rather than filament types: HTPLA, PolyTerra PLA, PLA Matte, ASA
Extrafill, Flexfill TPU 98A.

So the four routes that configure a slot reduce the material to a name
the printer knows before sending it, and the product name moves to
tray_sub_brands -- which is where Bambu Lab puts it too: their catalogue
carries a preset named "eSUN PLA+" whose type is PLA. A name the
reduction cannot place is sent exactly as before rather than guessed at,
so this can only repair a slot, never break a working one. The spool's
own wording still leads the id and temperature lookups with the reduced
type appended behind it, so "PETG HF" keeps its own generic preset
(GFG96) rather than being traded down to plain PETG's.

Two guards decide whether a candidate filament id is really a material
name -- the resolver's, which discards one, and slot reuse, which will
not carry one forward. Both saw only bare types, so "PLA+" passed as a
filament id. They share one answer now, which also refuses to read an
id-shaped value: "GFPLA" ends in a material name, and reducing it would
throw away the calibrated preset in the slot.

One thing had to move with it. on_ams_change auto-unlinks an assignment
whose slot stopped looking the way it did when the spool was assigned,
and the check that spares a slot Bambuddy itself reconfigured compared
the printer's reported type against the spool's raw material. With the
slot now carrying the reduced type, every spool this issue is about
would have been unlinked from the slot it had just been assigned to.
Both sides are reduced there -- the printer's too, so slots configured
by an older version, still reporting "PLA+", keep matching.

Something starts working as a result: a slot holding a calibrated preset
is reused when a same-material spool is assigned to it, which could not
happen for these spools while "PLA" and "PLA+" compared unequal.

Reverting any one of the behaviours above fails a distinct test -- the
reduction's four matching rules and its pass-through contract included,
since that contract is what makes the rest of it safe.
2026-08-22 11:41:28 +02:00
maziggy 7c8f1f9435 Keep a dispatch's retries out of the FTPS cool-off (issue #2898)
A failed TLS handshake arms a 300s per-IP cool-off, and connect()
consulted it for every caller. A print dispatch retries after 2s, so
once the cool-off was armed all four attempts were answered from the
gate rather than the network, and every further job queued for that
printer failed the same way for the rest of the window. The reporter's
farm lost three jobs to one handshake error, with the retry budget
contributing nothing to any of them.

The gate was serving two callers that want opposite things from it. The
background sweeps -- the post-print 3MF, cover and timelapse fetches --
walk ~110 candidate paths against one wedged printer with nobody
waiting, and backing off for minutes is right for them. A dispatch is
one delete plus at most four upload attempts with someone watching a
progress bar. So the split is by caller: a client built with
respect_handshake_cooloff=False goes to the printer regardless, and the
dispatch's delete and upload -- and a firmware upload, same shape --
opt out. Everything else keeps #2780's behaviour untouched.

In the reported trace it is the pre-upload delete that takes the SSL
error and arms the cool-off, 8ms before the upload's first attempt, so
exempting the upload alone would have left one dispatch's worth of the
problem in place.

Callers that do respect the cool-off no longer sleep out a retry loop
against it: with_ftp_retry takes the printer's IP and stops at the
attempt that armed the gate, instead of spending three more attempts
and six seconds on connections that cannot happen. It also reports the
attempts it really made -- "failed after 4 attempts" for one attempt is
part of how this read as a network problem.

Two diagnosis fixes go with it. The cool-off skip was the one connect()
failure path that reported without naming its cause, and at DEBUG, so
four identical reason-free warnings were all the operator saw. It now
says at WARNING that nothing was sent and how long the printer has
left, once per cool-off rather than once per attempt -- not every
caller is gated, and a download-zip of 200 files would otherwise repeat
the sentence 200 times, which is the flood #2780 set out to stop.

And a dispatch that fails this way no longer tells anyone to check
whether the SD card is inserted and formatted -- nothing reached the
printer's filesystem, so the card is the one part of the machine that
was working. The message names the file service and rules the card out.
It is used only when a handshake failed during the dispatch itself,
read from the cool-off deadline MOVING rather than merely being armed:
the dispatch ignores the gate, so it can be running underneath one an
unrelated background fetch left behind, and blaming TLS for an upload
that really hit a full disk would repeat the mistake in the other
direction.

Tests count sockets rather than return values, since "returned False"
looks identical whether or not anything was attempted -- which is what
made the original report a log dive. Reverting any one of the five
behaviours above fails a distinct test.
2026-08-22 10:05:05 +02:00
maziggy 3901043238 Name an AMS slot's colour by its material, not its hex alone (issue #2875)
A hex is not one colour in Bambu's range. #FFFFFF is Jade White in PLA
Basic, Ivory White in PLA Matte and plain White in six other materials;
popover resolved its title from the hex alone, against a map that keeps
one name per hex, so an ivory Matte spool read "Jade White" while the
profile line beside it correctly read Matte Ivory.

/inventory/colors/map now carries the names collapsing loses, keyed
"<material>|<hex>". An entry is emitted only when it recovers a name the
same manufacturer's own range lost -- 11 of them against the 608 colours
in the shipped catalog. Both halves matter: a name equal to the flat
answer is weight, and a name from another brand is not a recovery, it
would put Prusament's "Pristine White" on every generic white PLA slot.

A slot with a spool assigned from Inventory is titled with that spool's
own colour name: it is the roll the user said is in there. Bambu
internal codes are still rejected as non-names (#857).
2026-08-19 09:06:11 +02:00
maziggy 28781ea558 Keep the printer's name on statistics after it is deleted (issue #2873)
Every per-printer breakdown resolved the name against the printers that
exist now, so deleting a printer and choosing to keep its prints turned
"Ultron" into "Printer 1" in Prints by Printer, the success-rate and
time-accuracy lists, and Failures by Printer. Archives lose their printer
on that delete as well, so nothing was left to read a name from.

The runs themselves recorded the name they printed on. /archives/stats now
reports the last name each id was known by - taken from the newest run that
has one, so a later name-less row cannot blank it - and failure analysis
falls back to the same thing for ids with no printer left. The client keeps
preferring a live printer's own record, so a rename still shows up straight
away rather than after the next print.
2026-08-18 14:04:18 +02:00
maziggy cfecfa360e Restore the skip-objects list after a restart mid-print
The object list lives in PrinterState and is filled by the print-start path,
which bambu_mqtt suppresses on the first RUNNING push after startup so a
running print is not archived twice (#1304). Everything else that moment
restores came back - the archive into _active_prints, the filament
attribution session, the timelapse baseline - and the object list did not.
So the card saw zero objects and greyed out its Skip button for the rest of
the print. Measured on the maintainer's H2C: 8 objects loaded at 09:02, a
restart at 09:17, Skip dead for the remaining hour.

Nothing could bring it back either. GET /print/objects rebuilds the list
whenever it is empty, but its only caller is the modal that the greyed-out
button opens.

on_print_running_observed now reloads the objects from the archive of the
print that is still running, anchored on subtask_id - the firmware mints one
per print, so a leftover status="printing" row from a completion that was
never seen cannot lend its objects to another job. Without an id nothing is
loaded rather than guessed; the endpoint's own reload covers that on demand.

That endpoint now reads the archived 3MF from disk before it asks the
printer. The archive of a running print normally holds the very file the
printer is executing, so the fan-out was fetching back 15 MB Bambuddy
already had, over the printer's single FTP socket, while it was printing -
and on a printer that kept the file on internal storage it cannot succeed at
all. FTP stays as the fallback. skipped_objects is left alone: a reload is
not a new print, and what the user has already skipped only lives there.

The plate image had the same fault one layer down. Opening the modal asks
for the cover, the top view and the object-ID mask, and the in-memory 3MF
cache those share dies with the process - so after a restart all three went
back to the printer at once: three fan-outs, thirteen seconds, and a 0-byte
read from socket contention, which is the storm #972 was about. The cover
flow takes the running print's archived file too, resolved in the caller's
short-lived session and passed in so _produce_cover_image still does no DB
work, and marked as a shared file so the cleanup cannot delete the archive.

Finally the card: a running print always has at least one object, so a count
of zero means "not loaded", not "nothing to skip". Exactly one object is the
real nothing-to-skip case and still disables the button.

---

Stop a test's printer client leaking into the next test

POST /api/v1/printers really connects, so a test that creates a printer
through the API leaves a live client in the printer_manager singleton. The
singleton outlives the per-test in-memory database, so the next test on that
xdist worker - whose own first printer is handed the same primary key - reads
that leftover client as its own live status.

test_scheduled_drying_routes was the visible victim: an "online" printer with
no firmware version fails the drying preflight, so scheduling came back 400
instead of 200. It only bites when --dist load happens to put victim and
leaker on one worker, which is why it passes on its own and flakes under -n.

Registrations made during a test are now undone after it, ids the test did not
add are left alone, and disconnect_printer is what also drops the model and
printer-info caches and stops the paho thread the leaked client was keeping
alive against an unreachable address for the rest of the run.
2026-08-18 13:43:46 +02:00
maziggy d227d42272 Let a print with no 3MF be given its filament weight (issue #1820)
When the sliced file stays somewhere Bambuddy cannot read, the archive is
built from the printer's report alone and carries no weight. Nothing could
supply one afterwards: rescan reads the figure out of the 3MF, and that
archive has no file to read. The reporter's H2S print left 46.16 g on the
spool with nothing recording it, and he corrected Spoolman by hand.

Edit Archive now has a Filament used (g) field. It is written to the
archive's most recent run as well, because the Projects roll-up and the
Prometheus counter sum PrintLogEntry rather than the cards - correcting
only the archive would fix the display and leave every aggregate reading
the old figure, or none at all.

But not over a figure the run measured for itself. A run's grams come from
the tracked spool delta when there is one and only fall back to copying
the archive's estimate when there is not, so mirroring unconditionally
would overwrite a measurement with a typed estimate. The mirror now takes
a run that has no figure, or one holding exactly what this archive held -
which also makes the undo complete, since clearing the archive clears the
copy it made and leaves a measured run alone.

The field is text rather than a number input. A number input reports an
empty string for anything the browser judges malformed, a decimal comma in
a locale that does not expect one included, and that reads here as "the
user cleared it" - it would have wiped a good figure while the field still
showed what was typed. Filtering on the way in keeps what is displayed and
what would be sent the same string, and clamps it to the range the API
accepts: this modal has no error surface, so a refused save looks like
nothing happened at all.

Saving also invalidates the archive's runs query. The Print Log this modal
renders at its top reads them separately and kept serving the pre-edit row,
so a correction looked like it had not taken - true for the status and
failure-reason mirrors since #1444 as well.

Second half of the same report: the internal-storage probe (#2856) logged
which file it found but not where. On a printer that keeps uploads for
weeks - the reporter has months of them in /cache - a reprint of a name
that was re-sliced but never re-sent can match an older copy, and without
the directory that mismatch is invisible rather than merely rare. The
download helper returns the path that served the file instead of a bare
flag; every caller only ever tested it for truth.
2026-08-18 10:36:42 +02:00
maziggy 05d87a9740 Release the plate-clear gate on a powered-down printer (issue #2864)
POST /printers/{id}/clear-plate answered 400 "Printer not connected" for
anything without a live MQTT client, and the printer card hid the button
under the same condition. With Auto Power Off that is the ordinary end of
every print: the reporter's log has printer 1 marked offline at 12:00:55
by the plug and the clear-plate POST rejected at 12:03:12, with the plate
already cleared by hand. Nothing could release the gate short of powering
each printer back on, clearing, and switching it off again.

Nothing in the clear path talks to the printer. set_awaiting_plate_clear
writes an in-memory set and the printers.awaiting_plate_clear column, and
that column exists precisely so the gate survives an Auto Off cycle
(#961). The guard came in with the endpoint in aa87e5598, copied from the
stop/pause/resume handlers beside it, where reaching the printer is the
whole point. The scheduler already reads the flag off a powered-off
printer - it refuses to wake one that is still gated - and the wiki
recommends the MQTT topic for automations because it does not depend on
the printer being powered on. Only the write path disagreed.

The card follows: showClearPlateButton drops the connection term, and the
expanded-view button - which lived inside the block that renders nothing
without a live status - is shared and given its own slot below it. The
bulk filter tests clearPlate before the connection filter; every other
bulk action still needs to reach the machine. The plate pill stays
connected-only, since its only render site is inside that same block.

Two things on the same path needed the flag without a client. GET /status
returned the schema default for a printer with no cached state - manually
disconnected, or not yet reconnected after a restart - reporting a clean
plate the database disagreed with, and hiding the control on exactly the
printers that needed it. And _emit_plate_clear_change bailed when the
printer info cache was empty, which would have left the retained
plate_clear topic (#2525) asserting "awaiting" after the gate was
released; it now falls back to the row.

This does not dispatch to an unreachable printer: _is_printer_idle still
requires a connection. Releasing the gate is what lets the queue switch
the printer on for the next job instead of passing it over.
2026-08-18 09:34:03 +02:00
maziggy 607b34e94d Check the card before writing a print off as internal-storage-only (issue #2856)
A print's dispatch says where the printer put the sliced file:
ftp://<name> for external storage, brtc://emmc/<name> for internal. Since
that there is then no file to find at any path.

That is where the printer chose to put it, which is not the same as where
port 990 can read it. The reporter's H2D - firmware 01.03.00.00, card in
the slot - reports brtc://emmc and keeps the same file under /cache: his
log has every print from 08-12 downloading from there, 19 MB included,
until the skip landed and two days of archives came out as a name and
nothing else. #2780's P2S and H2C really did 550 on every path, so both
are true and the URL alone cannot tell them apart.

So ask the printer rather than the model. The dispatch names the exact
file, which turns the question into one connection walking five
directories - against the sweep's ~110, which is the cost that made
skipping worth doing. A hit archives normally and is shared with the
cover endpoint; a miss keeps #2780's fallback archive and its reason, so
the archives banner still explains itself. Not probed when the printer
reports an empty slot, or while its file service is in TLS cool-off:
both have already answered the question.

The connection diagnostic asked the same question off the URL and warned
that the last print was out of reach. On this reporter's printer that
warning would have sent him to a setting that was already right, so it
now probes too - by directory listing, since the file it is asking about
can be tens of megabytes and the answer is a yes or a no. Capped at 6s to
stay inside the support bundle's per-printer budget, and "could not
check" leaves the warning standing.

The probe filename arrives over MQTT and becomes both a remote path and a
local temp filename, so names carrying separators, traversal or control
characters are declined rather than cleaned.
2026-08-17 14:50:40 +02:00
maziggy 28b2b9f151 Pin the PostgreSQL session to UTC so defaulted timestamps are UTC (#2855)
On a UTC+3 install every AMS humidity reading and every archive was
stamped three hours ahead of when it happened. Bambuddy stores naive
timestamps that hold UTC and the frontend's parseUTCDate reads an
offsetless timestamp as UTC, so the display added the offset to a value
that was already local.

The Python side has honoured that contract since #504. The reporter's
timestamps were not written by Python. Around ninety-six columns take
their value from server_default=func.now() and the migration DDL carries
another forty-nine on DEFAULT CURRENT_TIMESTAMP -- the database fills
those, and on PostgreSQL now() is a timestamptz, so storing it into a
timestamp without time zone casts it through the session TimeZone. A
Postgres container started with TZ=Europe/Istanbul bakes that zone into
postgresql.conf at initdb, and every defaulted column then receives local
wall-clock. recorded_at is the clearest case: nothing in the codebase
ever assigns it, so its value is entirely whatever the database decided.

Connections now carry timezone=UTC, which makes the cast a no-op whatever
the server is set to. Measured through the real engine factory against a
live PostgreSQL, a session on the reporter's configuration stored +10800s
and the fixed one +0s. Pinning the session was preferred over a hundred
and forty-five individual edits partly for its size but mostly because
half of those sites are raw DDL that no model-level change can reach.

SQLite needed nothing and gets nothing: its CURRENT_TIMESTAMP is UTC by
definition and it has no session timezone to get wrong, which is why this
survived two years of timezone fixes without showing itself. That also
makes it the reference -- the change moves Postgres onto SQLite's
behaviour rather than introducing a third convention -- so the SQLite
behaviour is now pinned by a test instead of being assumed. asyncpg is
the documented driver and takes the setting in its startup packet; any
other Postgres driver gets the same setting the libpq way, so a psycopg
URL does not fail at connect on a keyword asyncpg alone accepts.

Rows already written are deliberately left alone. The inverse cast is
computable and DST-correct, but it cannot be applied safely: created_at
is assigned explicitly on some paths and defaulted on others, an install
that began on SQLite holds correct and shifted rows side by side, and
nothing distinguishes them after the fact. Timestamps are right from the
upgrade forward and history keeps the times it was given.

One related mismatch goes with it, because fixing the database side alone
would have made it start lying on exactly the installs this repairs. The
support package's oldest_pending_age_seconds subtracted a naive local
clock from a naive UTC column, with a comment claiming it was UTC; on the
reporter's install the two errors cancelled. It reported a job queued
five minutes ago as three hours old east of Greenwich and a negative age
west of it. The two AMS and printer-sensor retention cutoffs move to the
same utcnow_naive helper -- correct in value already, but deprecated in
3.12 and emitting warnings on every sweep.
2026-08-17 08:05:02 +02:00
maziggy c5e0055864 Track which nozzle each AMS feeds through the Filament Track Switch
With a switch fitted, an AMS is not wired to a nozzle any more. It is
plumbed into one of the switch's two inlets and reaches both nozzles
through it, so every unit reports its extruder as "not fixed" (0xE) and
ams_extruder_map comes back empty. Bambuddy had nothing to fall back on
but the AMS unit number, so AMS-A was badged R and AMS-B was badged L
purely because their unit ids are 0 and 1, a third unit got no badge at
all, and every one of those labels was wrong.

The binding needed no new telemetry. BambuStudio reads it out of bits
24-27 of the same AMS info string we already parse for the type and the
extruder id -- 0 is In-B, 1 is In-A -- and it only means anything when a
switch is installed, because without one 0xE really does mean an
uninitialised unit. That gates the read, which forced the switch block to
be parsed before the AMS block: _handle_ams_data runs early in
_process_message and _update_state only much later, so the binding was
lost on every frame that carried both.

The badge letters stay L and R, In-A reading as L, with the inlet named
in full in the tooltip -- the letter is the inlet's position and not a
claim about which nozzle that AMS feeds, since the switch can route
either inlet to either outlet. Both views update live now. The switch
fields were missing from the WebSocket payload, and the frontend
shallow-merges each push over its cached status, so an absent field kept
whatever the last full fetch left behind. The broadcast dedup key had no
term for them either, so "Join IN-B" on the printer screen moved nothing:
the binding is not in the tray component of that key, and not in the AMS
change-hash, which covers tray fields only and must stay that way because
it drives Spoolman sync.

The rest of this is the calibration half, which is where it actually
bites. K-profiles are calibrated per nozzle and the printer numbers its
calibration table per nozzle too, so entry 16 exists on both hotends and
means a different profile on each. A tray holds exactly one index. Move
an AMS to the other inlet and every configured slot in it silently keeps
pointing at the old hotend's table -- measured on the maintainer's H2C, a
black PLA calibrated 0.018 left and 0.020 right stayed on the left
profile after the move, and a manual RFID re-read only re-asserted the
same wrong one. Bambu has not solved this either; their AMS dialog
carries "TODO: fila_switcher broken the connection of ams->extruder"
above the line that decides which nozzle's profiles to offer.

Three copies of "which extruder is this slot on" each ended in else 0,
which on a switch machine filed every profile under the right-hand
nozzle. They now share one resolver that returns None for "unknown",
because unknown and extruder 0 are very different answers on a
dual-nozzle machine and conflating them is what bound a left-nozzle
profile to a slot sitting on the right. The per-slot K value on the
printer card had the same confusion from the other direction: its lookup
was keyed on cali_idx alone, so one nozzle's K silently overwrote the
other's.

Moving an AMS now re-selects each configured slot's counterpart profile
for the nozzle it has arrived on. Only the calibration binding moves, and
only for slots whose spool already has a profile for that nozzle:
configuring a slot is a deliberate preparation step, so a slot we know
nothing about, or a spool calibrated on one hotend only, is left exactly
as the operator set it. Nothing fires on the first sighting of a binding
either, since every reconnect learns them afresh and re-applying there
would overwrite a choice made by hand.

Configure Slot resolves against the slot's own nozzle throughout. Option
identity carries the extruder, so a filament calibrated on both hotends
gives two distinguishable entries instead of two that collapse into
whichever the printer listed first; options name the hotend; matches are
scoped to the nozzle the slot feeds, with the other hotend's profiles
still reachable under Other K profiles; and the slot's active index is
resolved as a pair rather than followed into the wrong table.

Inlet to nozzle is one table, In-A to the left hotend and In-B to the
right, measured rather than assumed -- fila_switch.out cannot be used for
it, reporting [1, 1] unchanged across a 90-second capture, both outlets
claiming the same extruder. The print dialog picks up the same inlet
labelling in its slot dropdown, replacing a left/right hint that never
rendered because it matched snow-encoded values against global tray ids;
decoding it correctly would not have saved it, since the firmware never
reports which inlet is currently paired with which outlet. The dialog
also notes when every filament a print needs sits behind one inlet, which
is legal but slow -- a change between two filaments on the same inlet
retracts the outgoing spool all the way back to its AMS, where a change
across the two only retracts as far as the switch.

Assigning an AMS to an inlet remains printer-side. BambuStudio can read
that binding and has no command to write it, so there is no wire format
to copy.
2026-08-16 15:59:59 +02:00
maziggy 7a42e0a7e5 Show which Filament Track Switch inlet each AMS feeds
With a switch fitted, an AMS is not wired to a nozzle any more. It is
plumbed into one of the switch's two inlets and reaches both nozzles
through it, so every unit reports its extruder as "not fixed" (0xE) and
ams_extruder_map comes back empty on these machines.

The printer card had nothing to fall back on but the AMS unit number, so
AMS-A was badged R and AMS-B was badged L purely because their unit ids
are 0 and 1, a third unit got no badge at all, and every one of those
labels was wrong. The SpoolBuddy assign modal had the same fallback in a
worse form, mapping anything that was not extruder 1 to R.

The binding turned out to need no new telemetry. BambuStudio reads it out
of bits 24-27 of the same AMS info string we already parse for the AMS
type and the extruder id -- 0 is In-B, 1 is In-A -- and it is only
meaningful when a switch is installed, because without one 0xE really
does mean an uninitialised unit and those bits carry nothing. That gates
the read, which in turn forced the switch block to be parsed before the
AMS block: _handle_ams_data runs early in _process_message and
_update_state only much later, so the binding was lost on every frame
that carried both. _parse_fila_switch is split out and called first, and
left in _update_state as well so that stays a complete absorb step.

The badge keeps L and R rather than A and B, because the lettering is
familiar and matches the physical layout. It is a different colour from
the plain nozzle badge, and its tooltip names the inlet in full, since
the letter is the inlet's position and not a claim about which nozzle
that AMS feeds -- the switch can route either inlet to either outlet. An
AMS still reporting a real extruder id keeps its ordinary badge, which
BambuStudio also treats as authoritative over any switch binding, and a
switch that has been fitted but not yet set up on the printer shows
nothing rather than a guess.

The print dialog's slot dropdown gets the same label. It replaces a
left/right hint that never once rendered: ftsExtruderForSlot compared
snow-encoded in[] values against global tray ids and could not match.
Decoding it correctly would not have saved it -- the firmware reports
which slot sits in each inlet and which nozzle each outlet feeds, but
never which inlet is currently paired with which outlet, so no per-slot
nozzle can be derived. That function is gone rather than fixed.

The dialog also points out when every filament a print needs sits behind
one inlet. Bambu's own guidance is that this is legal but slow: a change
between two filaments on the same inlet retracts the outgoing spool all
the way back to its AMS before the next can be fed up the shared tube,
where a change across the two inlets only retracts as far as the switch.
All on one inlet means every change in the job takes the slow path, and
moving a single spool fixes it. So it advises, it does not block.

Both views update live. Two things were stopping that. fila_switch and
ams_switch_inlet were absent from printer_state_to_dict, and the frontend
shallow-merges each WebSocket push over its cached status, so a field the
push omits keeps whatever the last full fetch left behind. And the
broadcast dedup key had no term for either, so "Join IN-B" on the printer
screen moved nothing: the binding is not in the tray component of that
key, and it is not in the AMS change-hash either, which covers tray
fields only and must stay that way because it drives Spoolman sync.

Assigning an AMS to an inlet remains printer-side. BambuStudio can read
the binding and has no command to write it -- its switch class is parse
and getters only, and the recommended-arrangement popup draws and
publishes nothing -- so there is no wire format for us to copy.

Adding the two fields to PrinterState broke four test modules whose
SimpleNamespace stubs predate them. The stubs are fixed rather than the
production reads made defensive: the real dataclass always carries both,
and a getattr in the dedup key would silently stop tracking the field if
it were ever renamed.
2026-08-16 15:09:00 +02:00
d37ce94f81 Feature: Scheduled drying (#2703)
* feat: add ScheduledDrying model for delayed drying runs (#2638)

* Release the printer when a scheduled dry ends (#2638)

_check_scheduled_dryings marks a printer as drying in _drying_in_progress,
which is shared with auto-drying. Auto-drying prunes that map in
_sync_drying_state(), but that call sits behind its enabled check, and this is
the first writer that runs whether auto-drying is on or not. With it off --
the default -- nothing dropped the entry short of a print being dispatched to
the same printer, so the next scheduled run parked on "already_drying"
forever and queue_drying_block held that printer's prints too. A nightly
off-peak dry with no printing in between is exactly the workflow this feature
is for: night one worked, every night after it silently did not. The check now
releases what it acquired, covering both a run that ends mid-pass and one
cancelled through the route between passes.

The retention prune ran on every pass. Issuing the DELETE is what opens a
write transaction, this method is called every 3s while the queue dispatches,
and rows only become prunable a week after they finish, so it is now gated to
hourly on a monotonic stamp -- with the first pass after a restart still
reaping whatever the dead process left behind.

Both drying paths now pick the blocking dry_sf_reason through one rule.
The immediate endpoint quoted whichever code the firmware listed first while
the scheduler prioritised power over retract, so one blocked AMS read two ways
depending on which button you pressed. drying_preflight.primary_reason_code
holds the order and both call it, including the flame button's tooltip, which
had no wording for filament at the outlet at all and sent those users to the
generic "can't start drying right now".

scheduled_drying joins the model list in init_db. The table was already
created -- importing the package registers it -- but it was the only model
relying on that indirection.

Tests: the release (completion and route-cancel), the prune throttle, the
shared reason rule, the tooltip priority, and four driving the real check_queue,
which nothing covered before -- a pass with no rows still dispatching prints, a
due row dispatching, and a failed row not stalling the queue behind it. Each
one fails against the code it guards.

---------

Co-authored-by: MartinNYHC <martin@bambuddy.cool>
Co-authored-by: maziggy <mz@v8w.de>
2026-08-15 13:40:21 +02:00
maziggy f3b6a503bd fix(profiles): read the companion files that hold a preset's real gcode
A bundled preset can keep a setting in `<preset> template <key>.json`, a
file the preset itself does not reference -- the desktop slicer finds it
by name. Walking only `inherits` never reached it, so every one of the 56
instantiable BBL machine presets resolved `machine_start_gcode` to the
577-character generic block on fdm_machine_common instead of its own
6.5-21 KB one. That block holds the M620 AMS load and the M1002
gcode_claim_action calls, so a print sliced from it heats the bed, moves
the toolhead and extrudes nothing (bambuddy#2838).

Companions are now folded into each ancestor as the chain is walked, at
that ancestor's precedence, so a caller's own value still wins and the
0.2/0.6/0.8 variants reach their 0.4 sibling's companion. They are found
by listing rather than by a fixed set of keys.

Covered against the shipped bundle, not fixtures: a new e2e spec resolves
all 56 presets inside the image and fails on any that still lands on the
generic block.
2026-08-15 11:37:01 +02:00
maziggy a4795c5ca3 Let API keys read and run slicer pipelines (#1425 follow-up)
Every pipeline endpoint answered 403 for API keys whatever scopes the key
carried. PR A parked all three permissions on the admin denylist until the
run dispatch existed to decide about; it landed in PR C and the parking was
never revisited.

PIPELINES_READ now rides can_read_status. PIPELINES_RUN requires
can_queue AND can_manage_library together, so the allowlist gained tuple
values: a run slices into the library and then queues prints, and mapping
it to either flag alone would hand that flag the other one's authority.
The 403 names every flag the key is short of. PIPELINES_WRITE stays
admin-only -- a key can run the recipe, not rewrite it or clear the log.

Opening the run route also needed the cloud-owner fallback the direct
slice route makes: a pipeline can carry Bambu/Orca Cloud presets, and
resolving those reads a token off a user record that an API-keyed request
does not have. retry_failed forwards the new dependency explicitly,
since a direct call receives the Depends marker rather than None.
2026-08-15 10:38:51 +02:00
maziggy aff737999f Build the slice output's path from a name a folder can have (#2832)
A print's display name comes from inside the 3MF, not from the filename,
so a MakerWorld title arrives with its punctuation: "Planter Pot with
Drip Tray, 12 cm / 5 inches". The slice-to-archive sink used it verbatim
for the output folder and the output file, and a slash in a folder name
is not a character -- it is another folder. mkdir(parents=True) created
the level it implied and the file's own join added a third that nobody
had made, so the slice failed with ENOENT on a path that half existed.
Renaming the print first was the only way through.

Reduce a display name to a single path component before it becomes one.
Characters a name cannot hold are replaced rather than dropped, so the
folder still reads like the model's title, and the set is the one the SD
card already rejects -- which covers a Windows install too, where the
colon in "Model: v2" fails the same way. The name shown in Bambuddy is
untouched: a title is allowed its punctuation, and refusing the slash
would reject the name this was reported about.

The joins are asserted to stay under the archive directory. That was
already claimed by a SEC-PATH-OK marker on both lines, citing a
sanitiser that is defined in another module and was never called here;
without the marker the path-join backstop flags them both. The claim is
now true, and a future edit that reaches around the reduction is caught
rather than trusted.

The library sink takes the same embedded name, so it gets the same
reduction: managed storage names the file after a UUID and never saw
this, but an external folder writes the name as given.

Display names are also stripped of control characters on the way into
the database, in the schema and in the archive service. The validator
hands back anything that is not a string rather than iterating it, so
the field still answers a list or a bare int with a 422 instead of
accepting the one and failing on the other.

Display names are also stripped of control characters on the way into
the database, in the schema and in the archive service, cleaned before
the filename fallback rather than after it so a whitespace-only embedded
name still falls through to the filename. The validator hands back
anything that is not a string rather than iterating it, so the field
still answers a list or a bare int with a 422 instead of accepting the
one and failing on the other.
2026-08-14 16:02:53 +02:00
maziggy 0623cc46df Repair no-3MF archives' photos and their silent filament writes (#1820)
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.
2026-08-14 15:32:40 +02:00
maziggy 9a2b811566 Show the plug that powers the printer in the card's Power row (#2830)
A printer card has one Power row: a plug name, its draw, and the auto-off
and on/off buttons. Which plug filled it was decided by nothing -- the
endpoint returned the first row the database handed back that was not a
Home Assistant script, from a query with no ORDER BY.

For the reporter that was an enclosure exhaust fan, added before the
outlet their X1C is plugged into. The card showed the fan's name with
'--' for watts, offered to switch the printer off by cutting the fan,
and demoted the metered outlet to the small HA button row. The fan was
marked as not powering the printer and hidden from the card; neither
setting was consulted here, though controls_printer_power has decided
the scheduler's power-on pick since #2629.

Rank the candidates instead: switchable at all, controls_printer_power,
enabled, show_on_printer_card, reports power, lowest id. The first rules
out a script, which can only be run, and an MQTT plug, which the control
endpoint rejects as monitor-only -- and an MQTT plug is exactly the kind
that reports watts, so without it ahead of the power tiebreak the row
could land on a plug whose on/off button answers with an error. The last
is not cosmetic: with no ORDER BY, a plain UPDATE on PostgreSQL can move
a row and silently swap which plug the card calls the printer's power.

None of these excludes a plug. A printer whose only plug is hidden,
disabled or monitor-only still needs its Power row, because that row
holds the on/off button and the HA buttons are drawn inside it.
controls_printer_power sits above show_on_printer_card because the two
only disagree when the plug that really feeds the printer is hidden, and
letting a display preference win there points the power buttons at an
accessory -- the fault #2629 fixed. Power capability is read from the
configuration, not measured: this runs on every card render, and it is
approximate both ways, so it only breaks a tie.

The scripts endpoint shares the same pick and excludes it, so a
switchable main plug is not repeated as a button directly below itself.
A script is left in place: a printer whose only entities are scripts
falls back to showing one in the power row, and taking it out of the
button row too would cost it the one-click run it has always had.
2026-08-14 14:49:55 +02:00
maziggy fffa68ec55 Explain a print that never reached the printer's card, instead of sweeping for it (#2780)
Bambuddy reads a print's 3MF, cover and timelapse over FTPS on port 990,
which on every Bambu model serves external storage only. Under some
configurations H2-series and P2S firmware keeps the sliced file on
internal storage, where Bambu Studio put it over the port-6000 service,
and then no path on 990 can find it.

The print command has always said which of the two it used -- `url` reads
ftp://<name> or brtc://emmc/<name>. We discarded it and swept anyway:
~110 connections per print, all certain to fail, ending in an archive
card with nothing on it and no stated reason. In the reporter's bundle
all 35 dispatches to their H2C and P2S said internal storage, all 25 to
their X1C said external, and all 44 empty cards belonged to the first two.

Read the field, skip the sweep when it cannot succeed, and record which
reason applied. A printer that uses the card is unaffected, and so is one
we have no answer for -- silence is not evidence, and reading it as bad
news would break archives that work today.

The answer is held per print and dropped when that print ends, rather
than kept as a standing fact about the printer. Plenty of prints never
announce themselves: 14 of the 79 print starts in that bundle arrived
with nothing on the request topic, started from the printer's own screen
or picked up after a restart. Left standing, one slicer print to internal
storage would suppress the lookup for every screen-started print after
it, on a printer whose files really are on the card. The sticky reading
is kept for the connection diagnostic alone, which is run after the print
that prompted it and would otherwise have nothing to report.

Two things that pointed the wrong way go with it. The archives banner
told everyone to enable "Store sent files on external storage"; the
reporter had it on for the whole three weeks and it would not have
helped. The diagnostic passed a printer whose slot was empty, because it
read only the toggle -- an empty slot is now a failure naming the slot,
and a printer that has storage and still used its own is a warning. On
P1-series that empty-slot failure yields to the existing unsupported-model
skip: the toggle cannot be switched on there at all, so telling the
operator to insert a card would promise a fix inserting a card does not
deliver (#2524).

Also close FTP sockets on the failure paths, which dropped them for the
garbage collector -- 1813 in a day in that bundle -- and drop the advice
to restart the printer, which the reporter tried twice while a single
manual connection to the same printer handshook cleanly.

This does not make the affected prints archive in full; that needs the
port-6000 protocol tracked in #2762.
2026-08-14 13:40:12 +02:00
maziggy 3954d3a7e6 Choose which rack nozzle each filament prints from on an H2C (#1784)
The Vortek rack holds six hotends, and a multi-colour plate is sliced to
use a different one per colour so it can skip the purge. Which of the six
each colour takes is not in the 3MF. The same plate, sliced and sent twice
from Bambu Studio with a different choice each time, produces two files
that differ only in rounding in the last digit of a few extrusion figures
-- the filament grouping, the toolchange stream, the 120 nozzle-change
markers and project_settings.config are all identical. The choice travels
only in the dispatched nozzle_mapping.

Bambuddy had no way to state it, so those plates went out with no nozzle
assignment at all and the printer chose for itself. That is what levelled
on one hotend and printed with another, millimetres above the plate.

Every rack-bound filament now carries a position picker beside its AMS
slot dropdown, listing all six with the nozzle each holds. An empty
position, or one holding the wrong diameter or flow type, is shown greyed
out with the reason rather than hidden, so someone looking for position 4
finds it. The choice is per filament *group* rather than per slot, because
a group is one hotend: two filaments the slicer grouped together share it
and cannot point at different positions.

Nothing has to be picked. Positions are assigned automatically, preferring
one already loaded with that colour, which on the plate this was built
against reproduces Bambu Studio's own pick exactly.

A nozzle currently picked up onto the carriage is offered too. The
firmware drops its rack position from the report entirely rather than
sending a placeholder (#943), and refusing it would rule out the position
most likely to be wanted -- the one the last print left mounted. Only
recoverable when exactly one position is missing; two gaps are genuinely
ambiguous and stay unavailable.

Positions are re-checked at dispatch, not just when queued, because the
rack can be re-loaded in between. The two failure modes differ on purpose:
an explicitly chosen position that no longer fits stops the print, names
what the position now holds, and deletes the uploaded file from the SD
card so it cannot be started by hand either -- an operator who named a
hotend must not silently get a different one. An automatic assignment that
cannot be made instead falls back to letting the firmware choose, which is
what happened before any of this existed.

The pick is stored as {group: position} rather than as the expanded
nozzle_mapping, though that is what goes on the wire. That column means
"Bambu Studio decided, forward verbatim", and only the group-and-position
form can be re-checked against what is actually mounted at dispatch.

The existing multi-rack refusal in extract_nozzle_mapping_from_3mf stays.
It still guards the #2800 fallback, which can only ever name one rack id.

Measured on the maintainer's H2C: rack position n is physical nozzle id
15 + n, confirmed by cross-referencing two captured dispatches against
Bambu Studio's own dialog. extruder_max_nozzle_count names which carriage
is the rack straight from the file, and is read rather than assumed -- a
fourth independent confirmation of the carriage indices fixed in 45dc139.

The print dialog is also wider, on every printer. Its filament rows carry
the most horizontal content in it and adding a picker truncated names to
"Bamb...". The column widths themselves only change on a rack machine.

Tests: 44 unit covering the plan, the resolver, the mounted-nozzle
recovery and every refusal; 9 dispatch integration asserting the two real
captures end to end; 7 API round-trip; 33 frontend. The API ones exist
because two integration bugs got through a green suite that tested the
pieces and not the seams -- the group data reached only one of the three
filament-requirements routes, and the field was declared on every schema
except the create one, where Pydantic dropped it in silence.
2026-08-14 11:27:50 +02:00
maziggy 36d996e453 fix(slicer): stop a 3MF from switching off supports its process preset turned on (#2820)
--load-settings is authoritative, so since #1881 four support fields
travel the other way -- enable_support, the two filament slots, and
support_type -- lifted out of the source 3MF and written over the picked
process preset. Bambu's shipped presets all set enable_support: 0
because supports are a per-print decision, and without the carry a
project exported with PVA in the interface slot sliced single-material.

But the carry ran in both directions, and the off direction is the one
nobody asked for. Nearly every published model ships with supports off,
so slicing one against a custom preset that deliberately enabled them
stripped them back out. The reporter's preset sets enable_support 1,
support_type normal(auto), support_style snug; the slice came back
disabled and tree(auto). Only the style survived -- it is not one of the
four carried, and they had re-entered it in the slice dialog.

The source can now switch supports on, never off. Nothing is lost:
every shipped preset has them off, so a preset that has them on is a
deliberate choice by whoever wrote it, and a file that wants supports
still gets them with its slot assignments. A file that never declares
enable_support is treated as off -- no intent to act on.

The truthiness rule ("1", true, 1, and the forks that write neither) now
lives in one place as supports_enabled_in_config(), shared with
extract_support_filament_slots_from_3mf, which had it inline.

Also log the carry with the fields it took. The slice dialog shows the
picked preset's values, so a carried field silently disagrees with what
was on screen and this step logged nothing at all -- the report chased an
unrelated sanitiser line about the source file's own settings, which was
the only thing in the log that mentioned any of these keys.
2026-08-13 10:41:22 +02:00