Commit Graph
1487 Commits
Author SHA1 Message Date
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 90fac7b529 Keep card and row actions reachable without a hover-capable pointer (issue #2865)
Tailwind v4 compiles group-hover: inside @media (hover: hover) - the
shipped CSS has .group-hover\:opacity-100 sitting in exactly that block.
On a touch-only device the media query never matches, so the rule that
reveals the control is not merely never triggered: it is never applied.
A control written as opacity-0 group-hover:opacity-100 is invisible for
good. The reporter's iPhone screenshot shows the project card with
nothing where the "..." belongs, and Edit and Delete live only there.

So the hiding half is what has to depend on the pointer, not the
revealing half. A can-hover variant carries the query - the same one the
history thumbnail preview has used since it was written - and the six
controls behind it become can-hover:opacity-0 group-hover:opacity-100.
Without a hover-capable pointer no rule hides them and they simply
render; with one, nothing changes. Every reveal selector is specificity
(0,2,0) against the hider's (0,1,0), so which one wins does not depend on
where they land in the stylesheet.

Six controls were affected: the project card menu, the File Manager's
folder actions (reachable by accident today - the "wrap names" toggle
drops the hover class), duplicate preset, rename and delete tag, delete
print photo, delete plate reference.

Six more already tried to handle this, by viewport width under 768px on
the Archives cards and the File Manager's file cards. That covers a phone
and misses an iPad in landscape, which is touch-only at 1024px. They move
to the capability check and useIsMobile goes with them, along with the
isMobile prop threaded into FileCard.

opacity-0 also leaves a button focusable while invisible, so tabbing
through a card stopped on a control nobody could see. Focus now reveals
them, through group-focus-within on the wrappers and focus-visible on the
standalone buttons - neither is hover-gated.

jsdom does not evaluate media queries, so the tests pin the class
contract instead: a bare opacity-0 is the defect, because it applies
unconditionally while everything that undoes it does not.

The decorative hover reveals are left alone - the archive hash badge, the
project name overlay, the thumbnail preview, the swatch tooltips. Nothing
is unreachable there, only unseen.
2026-08-18 09:53:46 +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 5a05e03c8e Use the printer's own plug for energy when several are linked (issue #2859)
Per-print energy is one plug's meter read at the start of a print and
again at the end. Both readings asked for "the plug on this printer"
with scalar_one_or_none(), which raises on two rows. Linking a second
plug to a printer - a dry box, a filter fan, a lights script - therefore
stopped energy tracking on that printer outright, and did it silently:
the print-start handler logged the exception as an ordinary failure and
the print-end handler then reported "no start kWh recorded", which is
also what it says for a printer with nothing linked to it.

The assumption was never enforced anywhere else. The plug API rejects a
second Tasmota plug and deliberately allows any number of Home Assistant
entities, the UNIQUE constraint on smart_plugs.printer_id was dropped on
purpose, and every other consumer reads a list. These two call sites were
the last ones left from before that.

Energy now ranks a printer's plugs - the one that powers it first, then
by id so the start and end readings agree - and takes the first that
actually reports a counter, so accessories drop out with nothing
configured. Ranking rather than filtering: a printer whose only linked
row is disabled, or a script, used it before and still does. When none
of them measures anything the log names the ones it tried, so that stops
reading like "no plug configured".

Also: the plug page counted an online plug as offline unless it reported
energy, so a switch with no power sensor showed as offline for as long
as it stayed linked.

Existing archives cannot be backfilled - the starting reading was never
taken, so there is nothing to compute a delta from.
2026-08-17 12:17:12 +02:00
maziggy 2c7df27d1a Look for a print's file when the printer says it is on the card
(#2780 regression)

A print of a file already on the printer -- a reprint from the
touchscreen, from Handy, or a slicer send-to-storage followed by a print
-- reports its location as a path rather than as a fresh upload:
file:///media/usb0/<name>. Since #2780 landed on 2026-08-14 Bambuddy read
anything that was not ftp:// as "the printer kept this internally",
skipped the FTPS sweep, and archived the print with a name and timing
only. Measured on an H2D: the file was listable and downloadable over
FTPS at the moment Bambuddy declared it unreachable. Before that change
those prints archived normally, so this is a regression, and it is not
confined to the H2 series the change was about -- an X1C reprint from its
own screen loses its thumbnail exactly the same way.

That module's own rule is to skip only on positive evidence, and a
file:// path is not evidence of internal storage. It now reads the path:
the printer's model cache under /userdata is a genuine skip, anything
else is unknown and sweeps, which is what it did before. Unknown rather
than external on purpose -- the empty-slot check still runs ahead of it,
so a file:// print on a printer with nothing in the slot reports the
missing card instead of sweeping for something that cannot be there.

The user-facing copy shipped this morning is corrected in the same
change, because it was written before we understood how Bambu Studio
actually chooses. Its Print button always uses internal memory; only Send
offers Cache or External, and that defaults to Cache too. So the advice
now leads with the two routes that take one step -- start the print from
Bambuddy, or slice in OrcaSlicer -- and offers Send-with-External and a
separate print start as the way to stay in Bambu Studio. The earlier
wording named no remedy at all and blamed the printer's firmware for a
choice the slicer makes. Banner and diagnostic, thirteen locales, README,
bundle rebuilt.
2026-08-17 11:24:53 +02:00
maziggy 034f8ac2c3 Tell H2-series owners what to do about incomplete archives (#2843)
The no-3MF banner and the connection diagnostic both explained why a
print archived with only a name and offered nothing to do about it. The
explanation was also wrong in a way that mattered: both blamed the
printer's firmware and said no setting changes it, which reads as
"your machine is broken and nothing will help".

Measured on hardware today. Same Bambu Studio, same model, three
printers a minute apart, all reporting the external-storage option as
on: the H2C and H2D went to internal storage and archived with a name
only, the X1C went to the card and archived in full. The same H2C and
H2D sliced in OrcaSlicer put the file on the card and archived in full,
and turning the option off changed nothing about that -- OrcaSlicer
always uploads over FTPS. So the printer does whatever the slicer asks,
and the option governs neither slicer on this generation.

Both strings now name Bambu Studio rather than the firmware, and end
with the two routes that do work: start the print from Bambuddy, or
slice in OrcaSlicer. Both mention that a card or stick is still needed,
because "use OrcaSlicer" on its own trades one confusion for another --
with an empty slot it refuses to send at all.

Translated in all twelve locales, each reusing the term it already uses
for the setting name and for slicer metadata. Bundle rebuilt, since the
strings compile in.
2026-08-17 10:48:10 +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
maziggy 61a6ed1f20 Move Camera View Mode from Settings onto the camera button
Whether a camera opened in its own browser window or as a floating
overlay was one dropdown in Settings > General > Camera, applied to every
camera on the install. Deciding per printer meant leaving the Printers
page, changing the setting, coming back, opening the camera, and going
back again to undo it -- five steps for a choice you make while looking
at the printer you want to watch.

The camera button on the printer card is now a split control. The icon
opens the camera whichever way you opened the last one; the caret beside
it offers both modes, and picking one opens the camera that way as well
as making it the mode the icon uses from then on. A menu that only
changed a preference would have left the user a second click to do the
thing they had already asked for. The mode in effect is ticked.

The choice lives in the user's own browser, so two people watching the
same farm can each have the view they want. camera_view_mode survives as
the default a browser that has never chosen starts from, and is written
back when the user holds settings:update. The local value wins on read:
someone below that permission cannot write theirs back, and a preference
that silently reverted on the next render would be worse than none.

Two things were consolidated on the way through. The popup-opening code
-- saved geometry, and deliberately no noopener so the browser copies
sessionStorage and its auth token into the new window -- was duplicated
between the printer card and the Cam Wall tile handler; it is now
utils/camera, with the geometry parse wrapped so a corrupt
cameraWindowState falls back to defaults instead of throwing, which
neither copy did. The Cam Wall follows the same remembered mode, since a
tile has no room for a split button of its own.

The effect that force-closed every open overlay when the setting flipped
to window is gone. It made sense for a global switch; with the choice
made per click, closing viewers someone deliberately opened does not.

No new locale strings: the four the settings control used are reused as
the menu's labels and tooltips and the caret's own label, so all 13
locales stay in parity untouched.
2026-08-16 13:43:15 +02:00
maziggy 47a37618a0 Let a busy or offline printer take a dropped file (#2849)
Dragging a sliced file onto a printer card refused the drop unless the
printer was connected and neither RUNNING nor PAUSE. The overlay went red
with "Printer busy", handleCardDrop returned early, and the file was
discarded with no toast and nothing uploaded. The card's Print button was
hidden by the same condition, so both routes into "Print from Printer
Card" closed at once and the way through was the File Manager, uploading
and queueing by hand.

The gate never described a real constraint. Every print Bambuddy sends
becomes a queue item; dropping onto an idle printer only looks instant
because the scheduler dispatches it on the next pass. Busy is a timing
difference, not a different path. The modal has always passed
disableBusy={false} to PrinterSelector, and asapToastShouldPromiseLaterStart
exists precisely to say "this will start later" when the target cannot
take it now. cleanup_library_after_dispatch is a print_queue column
consumed at dispatch, not on close, so the transient upload survives
however long the item waits.

Offline is included for the same reason: the queue dispatches when the
printer comes back, so a machine that is powered down can be given work.

The overlay now says which one is happening -- "Drop to print" when the
job would start immediately, "Drop to queue" when it would wait, covering
a print in progress, a paused job, an AMS mid-cycle, a plate not yet
cleared, and a printer that is offline. The predicate behind that wording
is the one the modal already used for its own later-start notice, lifted
out of PrintModal into utils/printer as isPrinterCurrentlyDispatchable so
the card cannot promise something the modal contradicts a second later.

The drop is also gated on the permissions the flow actually exercises. It
uploads to the library and creates a queue item, so library:upload and
queue:create -- the pair the Print button beside it has always checked.
printers:control, which it checked before and never uses, meant someone
holding that alone got the file uploaded and then rejected by the queue,
leaving a library row behind with nothing pointing at it. The refusal now
names whichever of the two is missing instead of claiming the printer is
busy.

printers.cannotPrint is dropped in favour of printers.dropToQueue across
all 13 locales; its text was both unused and, after this, wrong.

The Print button stays inside the expanded-card block, so S-size cards
still show the drop zone and no button, exactly as before.
2026-08-16 12:59:59 +02:00
maziggy 713a85d114 Let a bug-report capture outlive the panel that started it (#2847)
Step 2 asks the user to reproduce the problem, and the panel sits over
the part of the app they have to reach to do it. Closing it was the
obvious move and it was wrong in two different ways, picked by timing
alone. Reopen inside five minutes and the reset-on-open effect put you
on an empty step 1 while the server stayed at DEBUG, with nothing left
in the flow that could stop it -- only Stop & Submit ever did. Leave it
closed and the cap fired behind you: the panel is hidden but mounted, so
the timer kept running, stopped logging and filed the report with no
window open and no confirmation.

A capture is now written down -- description, email, was_debug and a
start timestamp -- and the reset skips a run in progress, so the panel
reopens on the step it left. The disc turns amber while a capture is
going and Layout marks the compact header's button and offers Resume
report on the debug-logging banner, since a run started there ends at
that panel's button and not at the System page's raw toggle. If the cap
fires while the panel is closed, it opens first, so the submission
happens in front of the user.

Elapsed is measured against the start time rather than counted in ticks,
which a background tab throttles hard enough that five minutes was not
five minutes. That timestamp also lets a run survive a reload, which
matters because reloading is an ordinary step in reproducing a bug: on
mount a stored run is reconciled against /support/debug-logging and
resumed. One that outlived the cap unattended is not resumed and not
filed -- an hour-old description is not a report anyone still expects --
but its log level is put back, which is what stayed wrong indefinitely
before.

The screenshot is deliberately not persisted: a 1920px JPEG runs to
hundreds of kilobytes against an origin-wide budget, and it survives a
close either way.
2026-08-16 09:55:12 +02:00
maziggy a72f49be02 Stop the File Manager card menu from clipping its own top entry (#2846)
A grid card drew its action menu inside itself and clipped its own
overflow, so a card shorter than its menu lost whichever entry sat at the
top. An STL card is the shortest in the library -- a square thumbnail
plus a name and a size, around 270px against a seven-entry menu needing
closer to 310px -- and the entry it lost was Slice, since Print is only
offered for an already-sliced file. A 3MF carries a target model and a
print count, two more rows, so its card was tall enough and the button
appeared, which made this read as a rule about file types. It was not:
the shortest card lost its first item, whatever that item was.

The menu is now the shared ContextMenu, anchored to the kebab in viewport
coordinates the way the archive card has always opened its own. Being
fixed, it escapes both clipping ancestors -- the card and the grid's
scroll container, which would have cropped the top row of cards even
without the card's own overflow. The card drops overflow-hidden anyway
and the thumbnail rounds its own corners instead, so nothing a child
positions outside the card can be cut off again.

While rewriting the block, 3D Preview and its permission tooltip stop
being hardcoded English; fileManager.preview3d and
fileManager.noPermissionPreview are added to all 13 locales.

List view was never affected: it has no menu, only inline buttons.
2026-08-16 09:40:03 +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 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 8fe93169ca Remember the Printers page's status and location filters (#2833)
Pick a location, navigate away, come back, and every printer was showing
again. Both filters were plain useState, and the only preferences on that
page that were not remembered -- sort order, card size, view mode,
collapsed sections and hide-disconnected all persist, each with the same
initializer-plus-setItem shape. Give these two the same treatment.

A saved filter needs a way out, though. The location dropdown is only
rendered while at least one printer has a location, so a saved location
that was later renamed or removed would match nothing and take its own
dropdown off screen with it -- an empty page and no control to undo it.
A location that is not among the available ones now resets to all, and
the same for a status the dropdown does not offer.

That check waits for the printers query to resolve. The list is undefined
while it is in flight, so the available locations start out empty, and
acting on that would throw the saved filter away on every page load.

Search stays unpersisted: a box that silently refills itself on return is
a surprise rather than a convenience.
2026-08-14 16:16:35 +02:00
maziggy 71d506be0c Show the printer card thumbnail again after navigating back to the page (#2826)
The cover URL is cache-busted on the print name, which does not change
while a print runs. Leaving the printers page and returning therefore
re-mounts with a byte-identical src, which the browser serves from its
in-memory cache with no network request -- which is why the reporter's
network panel was empty while the placeholder sat there.

`loaded` could only ever be set by onLoad, and a mount effect reset it to
false unconditionally. For a cache hit those are two racing tasks with no
ordering between them: when the load event won, the effect undid it, and
nothing put it right afterwards because the URL does not change again for
the rest of the print. Read the element's own complete/naturalWidth in
that effect instead of assuming nothing has loaded. That settles it
whichever task wins, and also covers the variant the reporter proposed,
where the handler is not live in time.

Being a race explains why it reproduced 100% for the reporter and not at
all here. Only the printer card was affected; archive thumbnails build a
fresh URL every mount, so they always hit the network.

CoverImage is exported so the regression tests can mount it directly.
2026-08-14 14:04:10 +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 4d458a5202 Grow the H2C nozzle rack with the printer card size
The six rack chips were a hard-coded 28 pixels at every card size, while
the body type and icons around them scale by 20% at L and 40% at XL. Set
the card larger and the rack stayed put -- a shrunken strip beside
neighbours that had grown around it, with the diameter figures pressing
against the edges of chips that had not moved.

The chips now read their size from the same scale as everything else,
which needed one new rung: the icon tokens run in quarter-rem steps (i2
is 8px, i3 12, i4 16, i5 20), so 28px is i7. S and M are unchanged, as
they are for every other property that control scales.

The card is sized to its contents, so it simply takes the extra width
rather than being told a new one -- and at L and XL that row has the room
to give, so the temperature readings beside it do not go back to wrapping.

Two tests: one pins i7 to 33.6px at L and adds it to the sweep asserting
every token is set at XL, the other asserts the chip reads the token, so
a revert to a fixed class fails rather than silently regressing.
2026-08-14 09:26:33 +02:00
maziggy d0e217f65a Size the H2C nozzle rack card to its contents and number its slots
The rack card shared a row with the nozzle, bed and chamber readings but
was set to flex: 2 1 190px -- a 190px floor plus twice their growth share
-- to draw six fixed 28px chips. On anything wider than a compact card it
claimed several hundred pixels and left most of them empty, and the width
came out of the cards that needed it: the combined dual-nozzle reading was
wrapping "220 / 220" onto two lines beside a mostly blank rack. It is now
flex: 0 1 auto, so it takes its content width and gives the remainder
back. Shrink stays enabled so it still gives way on a narrow card instead
of overflowing.

Each slot also carries its physical rack position 1-6 below the chip, so a
nozzle can be named rather than counted along. The numbering is positional
-- an empty slot keeps its number -- so "the nozzle in slot 4" means the
same thing however many of the six are occupied.

The #943 regression test read every span in the slot row, which the new
number spans would have interleaved with the diameters; it now matches the
diameter spans specifically. A second test pins the 1-6 labelling across a
rack with empty positions.
2026-08-13 16:44:05 +02:00
maziggy e6842e1d3c Rank near-colour matches by how they look, and share one filament type table (#2804)
Three follow-ups to #2804, all bearing on one decision: which spool a print
uses when the exact colour is not loaded.

Colour ranking is now perceptual. The ranking added in #2804 measured RGB
distance, which rates a colour by how far apart the numbers are rather than
how far apart they look, and it overweights blue badly enough to invert the
answer: against a required #1E4821 green, a purple #38202F is the nearer of
two eligible spools by RGB and four times the further once measured properly.
Both sides now use CIEDE2000 -- perceptual_color_distance in
backend/app/utils/color_utils.py and colorDistance in amsHelpers.ts, kept
structurally identical so they can be read side by side. Verified against the
Sharma/Wu/Dalal published reference set, all 31 pairs to 1e-4, and the two
implementations agree to within 1e-9 across 800 sampled pairs. Eligibility is
untouched, still the per-channel RGB box, so this only reorders spools that
already qualified.

Type matching now agrees between the interface and the scheduler. Bambu
firmware treats PA-CF, PA12-CF and PAHT-CF as one material and the scheduler
has always matched them accordingly, but the interface compared raw type
strings and called that same pairing a mismatch. The badge contradicted what
the printer was about to do, and the manual override picker, which groups by
canonical type, offered the very spool the badge then rejected. The fifteen
comparison sites in useFilamentMapping.ts, useMultiPrinterFilamentMapping.ts
and PrinterSelector.tsx now call filamentTypesCompatible.

The pipeline pre-flight reads the matcher's table instead of its own copy.
That copy had drifted into disagreeing in both directions: it aliased PLA
Basic to PLA where the matcher never has, so a run could clear the check and
then fail to map its slots, and it lacked the nylon grouping, so it flagged
runs the matcher handles without complaint. A check whose job is to predict
dispatch is wrong whenever it disagrees with dispatch, whichever way it leans,
so it and the scheduler now both read backend/app/utils/filament_types.py.

That canonicaliser deliberately does not strip surrounding whitespace. It
looks like a free improvement, but it would collapse a junk tray_type to ""
just as a 3MF declaring no filament type yields "", and a typeless requirement
would start matching a junk-typed tray instead of reporting the slot unmapped.
Padded type strings are worth handling on their own terms, with that case
addressed.

One behaviour change outside the ranking: the pre-flight is stricter for a
printer reporting a product name such as "PLA Basic" where the generic
material belongs, which it now flags rather than passes. Rare in practice,
since the printer reports material and product name in separate fields, and it
is the answer the matcher would give. Nothing about which spool a print
actually uses changed outside the colour ranking itself.

Adds 203 backend and 6 frontend tests. The #2804 tie-break test now uses
identical colours: two colours at equal RGB distance are not perceptually
tied, which is rather the point.
2026-08-13 12:04:56 +02:00
Martin Grolmus 4f7a02b393 Pick the nearest eligible filament colour instead of the first one in tray order (#2804) (#2823) 2026-08-13 11:19:00 +02:00
maziggy 12e0a60e8b fix(ui): stop the Spool Inventory header from scrolling the page sideways (#2813)
Five header buttons in a row that could neither wrap nor shrink came to
~600px, so on a 390px screen the header ran past the viewport and took the
whole page with it -- <main> is the scroll container, so everything inside
it panned.

Stack below sm and wrap the actions, matching the pattern the Statistics,
Settings and Archives headers and this page's own filter bar already use.
Identical at >=640px. The System Information header had the same
construction with one button and gets the same treatment.
2026-08-13 09:31:13 +02:00
maziggy 4a65abe228 Tell the browser which colour scheme the page is in
The parts of a form control the browser draws itself -- a number input's
stepper, a date field's calendar button and popup, a select's dropdown,
scrollbars, the autofill tint -- were painted in the light appearance on
every theme. Bambuddy switches theme by swapping CSS variables under a
`dark` class, which the browser cannot see, so it assumed the page was
light and matched the steppers to a white background that was not there.

Declaring color-scheme alongside the variables fixes all of them at once,
in both directions. It goes on `.dark` rather than the per-palette
classes because the kiosk sets `dark` on the root element directly.

Three date and time fields had been pinned to dark by hand to work
around this and no longer need to be; being pinned, they were wrong
under the light theme anyway.
2026-08-11 12:16:31 +02:00
maziggy 0229d61cea Open multi-plate G-code on the plate that was asked for
Previewing a sliced multi-plate 3MF from the File Manager showed a plate
nobody picked. The library route took no plate parameter at all, so the
one the viewer has always put in the URL was dropped -- FastAPI discards
unknown query parameters silently. Both routes then fell back to the
first .gcode member of the zip, and member order is whatever the slicer
wrote: the reported file stores plate_2.gcode ahead of plate_1.gcode.
Nothing that opens the viewer from the File Manager passes a plate, so
there was no way to ask for another one either.

Plate resolution now lives in threemf_tools and both routes share it.
select_plate_gcode_name() returns the named plate or None, so a caller
serving an explicit choice can 404 instead of rendering something else;
default_plate_gcode_name() returns the lowest-numbered plate. The viewer
gained a plate switcher, and keeps the choice in its URL so a link to one
plate survives a reload. Filament colours follow it too -- they were
taken from the first plate regardless of which one was on screen.

G-code injection and the finish-photo max_z_height read shared the old
first-member fallback and now resolve the lowest plate as well.
2026-08-11 11:49:30 +02:00
maziggy df5aa04df1 Pool AMS backup spools in the print dialog's filament check
The dialog weighed each plate against the spool in the slot it mapped to
and knew nothing about AMS Filament Backup, so a two-plate job needing
1441 g of ABS was refused against a 1000 g spool while the identical full
spool in the next slot went uncounted. The dispatcher has pooled matching
spools since #1762 and would have run the print -- "Print anyway" was
always the right answer to this warning.

The rule for which spools back each other up now lives in one place:
build_slot_materials() in filament_deficit, which the dispatcher's pool
and the new slot_materials half of GET /printers/{id}/inventory-remain
both draw on. The dialog groups on the keys it is handed rather than
resolving spools a second time, which is what let the two answers drift
apart, and which also gives the check to Spoolman users -- it read the
internal inventory only, so in Spoolman mode it approved everything.

Where a pool really is short the warning quotes the pooled totals, since
the per-slot figure reads as a contradiction next to a full peer spool.
2026-08-11 11:32:17 +02:00
maziggy e93f1b43c3 chore(settings): drop the Slicer Bundles notice and rebalance the columns
Bundle import was withdrawn in 0.2.5 and the panel was kept behind as a
static notice pointing at the alternatives. It has been on screen for
several releases, it was shown to everyone running the slicer sidecar
whether or not they had ever imported a bundle, and it was a card in
Settings -> Queue & Dispatch that could not be acted on. Component,
render site and all thirteen locales' strings are gone; no slicing
behaviour is touched.

Four docstrings still described the removed feature as a live fallback:
SliceModal was said to fall back to "the user's uploaded Slicer Bundles"
when a preset carries no compatible_printers. There is no bundle model
and no bundle endpoint left -- the actual fallback is the @BBL <code>
printer-model registry, which is what SliceModal has been doing since

Removing the card left the left column of that tab noticeably longer, so
G-code Injection moves to the foot of the right column. The card is
unchanged and keeps its card-gcode anchor, so settings search still
jumps to it.
2026-08-11 09:57:20 +02:00
maziggy 3dc681d9f1 fix(slicer): write slice output to the source's external folder (#2810)
slice_and_persist always wrote to get_library_files_dir() while giving the
new row the source folder's id, so slicing a file on a NAS mount produced
a .gcode.3mf that showed up in the right folder in the UI and never
reached the share -- invisible from the web UI, which is why it did not
reproduce.

Resolve the destination from the target folder like uploads (#1112) and
moves already do, set is_external and store the absolute path. Collisions
uniquify to "Model (2).gcode.3mf": a 409 would throw away minutes of CPU
on a routine re-slice, and overwriting a file on someone's NAS is worse.

An external folder that cannot take the file (read-only, unreachable, not
writable) falls back to managed storage rather than discarding the slice,
and reports why on SliceResponse.external_write_fallback -- surfaced as a
warning toast. Silent fallback is what made this bug invisible.
2026-08-11 09:02:52 +02:00
maziggy b63adb8151 fix(virtual-printer): wrap the card header instead of overflowing it (#2808)
Every item in the collapsed header was flex-shrink-0, so the row was as
wide as its contents and the Card doesn't clip -- the remote-interface IP
and the enable toggle painted outside the card border. The name's
`truncate` couldn't save it: a flex item defaults to min-width:auto, so
it never shrank below its text (`flex-shrink-0 truncate` on the target
name was self-cancelling for the same reason).

Move the metadata into a flex-1 min-w-0 flex-wrap group so it wraps to a
second line, and keep the chevron, dot and toggle outside it. Wrapping
rather than truncating: the bind and remote-interface addresses are what
the page exists to show.

Needs three things at once, hence the report -- both IPs set (Bambuddy
and printer on different subnets), a target named "Printer at <ip>" from
discovery, and the 3-column card grid.

overflow-hidden is scoped to this card, not added to Card: half the cards
in the app render menus that deliberately paint outside their bounds.
2026-08-11 08:45:33 +02:00
MartinNYHC 37b0e25a2b Merge branch 'dev' into feature/queue-keep-warm-chamber-history 2026-08-10 13:31:28 +02:00
maziggy 57190b10ba Stop offering server-side slicing for STEP files
The Slice action appeared on .step / .stp and the endpoint accepted the job,
but neither slicer can load one from its command line -- both answer
"Unknown file format. Input file must have .stl, .obj, .amf(.xml) extension."
So the file was read, converted and uploaded before failing as "The input
model file to the slicer can not be parsed", which reads as a corrupt model
rather than an unsupported format.

The endpoint refuses a STEP up front with a message saying to export it as
STL or 3MF, and the Slice and pipeline buttons no longer appear on one.

Open in Slicer is unchanged and still hands STEP to the desktop application,
which opens it fine -- that was always the working path. isSliceableFilename
(desktop) and isApiSliceableFilename (sidecar) are now separate predicates so
the two cannot drift back together.
2026-08-10 13:09:24 +02:00
maziggy 7c117dc6bc Let API clients resolve user ids to names (#1894)
Archives, the queue and statistics report ownership as a numeric
created_by_id, and statistics accept it as a filter, but nothing let an
API key discover whose id was whose -- the only user listing returns
emails, roles, group membership and full permission sets, so it is
administrative and rejects keys.

Add GET /users/slim returning id + username only, gated on a new
users:read_slim permission mapped to can_read_status. That grants no
data a key could not already reach: for API-keyed requests the
permission deps return None as current_user, so the stats:filter_by_user
guard short-circuits and ?created_by_id=N is already honoured for every
N. What was missing was the ability to address the filter, not
permission to use it. The full listing stays unmapped = admin-only.

Also fix /auth/me, which answered an API key with a synthetic
administrator: id 0, role admin, is_admin true and every permission in
the enum. A key cannot reach an administrative route at all, so clients
building their UI from that response rendered actions that 403 on use.
It now reports the key owner's identity, is_admin false, and the
permissions the key's scopes actually admit. Ownerless legacy keys keep
id 0 but no longer claim admin.

---

Source user names from the slim listing where only names are needed (#1894)

Stats filter-by-user, the Archives print log filter, the File Manager
username autocomplete, the camera-token owner column and the Finance
member picker all render nothing but a username, but all of them read
the full user listing, which is gated on the admin-level users:read.
An operator granted stats:filter_by_user but not users:read got an
empty filter with no indication why.

Point them at /users/slim under a separate react-query key, since the
full listing shares the 'users' key and the two shapes would clobber
each other in the cache.
2026-08-10 11:43:23 +02:00
Thomas Scott Williams f86f5a7c34 feat(queue): keep the chamber warm between prints and skip redundant soak
Back-to-back prints in chamber-heated materials (ASA, ABS, PA, PC) each paid
a full heat-soak from cold, even when the print that just finished had left
the chamber at temperature. Two changes remove that cost.

Keep bed warm between prints
  While a printer sits in FINISH awaiting plate-clear and the next queued item
  needs chamber heat, hold the bed hot so the chamber does not cool during the
  bed-clearing window. The bed is the chamber's heating element here, not a
  print surface, so the hold runs at the new `queue_keep_warm_bed_temp`
  (default 90C, which also satisfies bed-threshold-linked aftermarket chamber
  heaters), raised to the item's own bed temperature when that is higher.

  Gated on `queue_keep_bed_warm` AND `require_plate_clear` AND
  `preheat_enabled`, all re-checked in the backend so a stale UI cannot leave
  the feature running. `queue_keep_warm_max_minutes` (default 120) bounds the
  hold: when it elapses the bed is switched off and the hold latches until the
  printer is next a candidate, so a plate nobody clears cannot leave the bed
  hot indefinitely. The hold is also released when the item is deleted, the
  queue empties, or a gate is toggled off mid-hold, and never when firmware
  reports a target other than the one it set — a temperature the user or a
  print changed is left alone. Publishing is idempotent.

Smart soak reduction from chamber history
  The scheduler samples each connected printer's chamber temperature every tick
  into a 2h rolling history. Preheat credits time the chamber has already spent
  at temperature against the configured soak, shortening or skipping it.

  Credit starts no earlier than the newest sample, the most recent unbroken run
  of samples, or the end of the last real dip below target. A dip only counts
  once it outlasts a grace period: an enclosed chamber cannot lose and regain
  several degrees quickly (measured on an X1C, cooling from 55C to below 48C
  takes 23-73 minutes, ~0.2 C/min), so a brief low reading is a door opening or
  sensor noise rather than lost soak — and a plate swap, which is exactly when
  keep-warm runs, produces one. A stale history credits nothing: at that
  cooling rate the chamber can cross the threshold unobserved, so the full soak
  runs instead.

Three supporting changes to preheat itself:

  * Cancelling or deleting a queued item now stops a preheat already running
    for it. Those routes only write `status` to the database, which a dispatch
    coroutine parked in `asyncio.sleep` cannot observe, so the heaters ran for
    the rest of max_wait + soak — 45 minutes at the default settings — and the
    printer stayed in `busy_printers`, blocking every other queued item behind
    a print that was not happening. The routes now signal the scheduler
    directly, and the stage sleeps in slices so it notices promptly and
    abandons the dispatch, letting the existing rollback shut the heaters off.

  * A chamber-heated print whose slicer metadata carries no bed temperature
    (common for Orca-exported 3MFs) used to skip preheat entirely and start
    with a cold chamber. It now heats the bed to `queue_keep_warm_bed_temp`.
    A parsed bed temperature still wins, and a print with no chamber
    requirement still skips — no bed temperature is invented for the print
    itself. Preheat's bed target is transient regardless: the print's own
    gcode issues its M140/M190 at start.

  * Preheat records which commands it sent (bed, chamber, airduct) and unwinds
    them if the dispatch aborts before the print starts — a failed upload, a
    cancelled item, an exception — instead of leaving the printer heating for
    a job that is not happening.
2026-08-09 17:07:34 -04:00
maziggy 08df660f6c Replace the embedded G-code viewer with the slicer's own renderer
Sliced files previewed through a vendored copy of PrettyGCode in an
iframe. It drew each move as a screen-space line -- a line has no
thickness in the scene, so it cannot occlude the layer behind it, which
is why prints came out stringy and shimmered where layers crossed. Being
a separate app in a frame, it could be neither themed nor translated, and
carried its own machinery for detecting a proxy refusing the embed.

Now built on libvgcode, the renderer OrcaSlicer draws its own preview
with, vendored from three-slicer (AGPL, same as us). It takes the THREE
namespace as an argument and imports nothing, so it runs on our 0.181
rather than the 0.160 its package pins.

The parser is ours; upstream renders its own kernel's output and ships no
G-code parser at all. Two things it has to get right, both found by
checking a real plate rather than assuming:

- BambuStudio does not use the OrcaSlicer/PrusaSlicer annotations. It
  writes "; FEATURE:", "; LINE_WIDTH:", "; CHANGE_LAYER" and
  "; Z_HEIGHT:", not ";TYPE:", ";WIDTH:" and ";LAYER_CHANGE". Reading
  only the latter showed a 52-layer print as 23,165 layers in one colour,
  because with no layer marker recognised every travel Z-hop split a
  layer and every segment took the fallback feature.
- It emits a tenth of its moves as G2/G3 arcs -- 706 extruding ones in a
  single plate. Ignoring them punched holes through curved walls and tree
  supports. Arcs with no X/Y are the helical travel lift and lay down
  nothing, so they interpolate as travels.

Four colour modes: filament (default, from the AMS slots the file was
sliced with), feature, layer height, line width. Speed, fan and
temperature are deliberately absent -- upstream derives those from
settings rather than the toolpath, and guesses dressed as measurements
are worse than an honest omission. The parser now carries the data to do
them properly later.

Legend entries are switches. Hiding removes the records before the mesh
is built rather than recolouring them: the shader packs colour into a
single float with no alpha, so there is no transparent to set, and
removal is the useful behaviour anyway -- a hidden support stops
occluding what it covered.

The scene is built once and only the toolpath rebuilds. Doing otherwise
constructed a new WebGLRenderer on every render, because the buildVolume
default is an object literal and so a fresh identity each time; browsers
cap live WebGL contexts and drop the oldest, which blanked the canvas
after a few interactions.

utils/framing.ts goes with the iframe, along with six now-orphaned
strings in all 13 locales. src/lib/vendor is excluded from eslint --
acting on findings in vendored code makes it impossible to re-copy on the
next upstream release.
2026-08-09 14:10:18 +02:00
maziggy 2d584e02a4 Render the previews properly instead of sketching them
Model preview
-------------
The camera distance came from `maxDim * 1.8`, which accounts for neither
the camera's field of view nor the viewport's aspect ratio, so a tall
narrow panel was framed as though it were square -- the model sat in the
middle with a screenful of dead space above it. Solved from the bounding
sphere against both fields of view instead, so it fills the frame at any
panel shape. Near/far now scale to the subject rather than staying at the
0.1/10000 defaults.

Lighting was two directional lamps over 0.6 flat ambient on a Phong
material: every surface facing the same way got an identical colour,
which is what flattened models into silhouettes. Now a MeshStandard
material lit by a generated RoomEnvironment through PMREMGenerator, with
ACES tone mapping so the lit side of a saturated filament colour doesn't
clip to white and drain the hue.

Added a contact shadow. Two things would have made it silently draw
nothing: the build plate is an unlit MeshBasicMaterial and cannot receive
shadows, so the catcher is a separate ShadowMaterial plane; and three's
default directional shadow camera is a +/-5 unit box, which nothing on a
256mm bed falls inside.

The PMREM render target is disposed on unmount -- it is GPU memory the
collector cannot reclaim, and this viewer is opened and closed repeatedly
from the file manager. Device pixel ratio is capped at 2; a 3x phone
screen was quadrupling fragment load for no visible gain.

G-code preview
--------------
Switched gcode-preview from `lineWidth: 2` to `renderTubes`. A 2px
screen-space line has no thickness in the scene, so it cannot occlude the
layer behind it -- hence the stringy surface and the shimmer where layers
overlap. Tubes are built from real extrusion width and height, so the
print occludes itself.

The flag is marked experimental upstream, and the 0.42 extrusion width is
a hardcoded default that is right for a 0.4 nozzle and wrong for a 0.6.
Both are worth revisiting if this holds up in use.

Modal
-----
Removed the G-code tab. G-code has its own full-page viewer, and a
preview of a model is a different question from a preview of a print.
That left dead weight behind it: the render branch, the GcodeViewer
import, the has_gcode capability (still computed, never read), the Code2
icon, and two orphaned strings in all 13 locales. One test was repurposed
to assert the tab is absent so it cannot creep back; two others only
exercised that tab's disabled state and went with it.
2026-08-09 12:00:11 +02:00
maziggy b6027b7138 Say why a preset's values are unavailable, not just that they are
The settings panel collapsed four causes into one message -- "the picked
preset's own values could not be read" -- with no indication of what to do
about it.

The overwhelmingly common cause has an obvious fix, and it isn't an edge
case: an install pulls its sidecar as SIDECAR_TAG:-latest regardless of
which Bambuddy channel it is on, so a current Bambuddy talking to a
sidecar that predates POST /profiles/resolve is the normal state, not a
misconfiguration. Those users would have seen an amber warning on every
slice with nothing pointing at the sidecar image.

resolve_profile now returns ResolvedProfile(values, reason) instead of
None for everything, the route passes the reason through, and the panel
picks its message from it:

  sidecar_outdated     -> name the fix: update the sidecar image
  sidecar_unavailable  -> the sidecar did not answer
  not_configured       -> no sidecar is configured
  preset_unresolved    -> the previous generic wording

A request that fails outright maps to sidecar_unavailable, since a
backend we cannot reach and a sidecar that will not answer are the same
thing from the dialog.

Every variant still ends with "anything you don't change still uses the
preset" -- that reassurance is the point of the notice, and it is true
whichever way the lookup failed.

Tests pin the distinction rather than just the happy path: a 404 and a
500 must produce different reasons, and each panel case asserts both that
its own message appears and that the "update the sidecar image" line does
not leak into the others.
2026-08-09 11:25:09 +02:00
maziggy f421bb8160 Show the picked preset's real values in the process-settings panel
The panel baselined every field on the option schema's compiled-in
defaults, so a preset setting a 0.42mm line width displayed 0 -- the C++
default meaning "derive from the nozzle". Every field was affected; the
Line width group just made it obvious.

Bambuddy cannot answer this itself. A standard-tier pick is only an
{inherits: ...} stub on our side, and local/cloud presets are deltas whose
remainder lives in the profile tree bundled inside the running sidecar.
The values now come from the sidecar's POST /profiles/resolve, which runs
the same resolver /slice does against the same profiles, so what the panel
shows cannot disagree with what a slice produces. Deliberately not the
local orca_profiles resolver: it walks OrcaSlicer's published tree, which
can differ from the image actually installed.

An untouched field shows the preset's value and reverting returns to it.
isModified compares against that baseline too, so fields the preset moved
off the C++ default are no longer flagged as user edits, and values nobody
typed are no longer sent. When the values can't be read -- sidecar offline
or older than the endpoint -- the panel falls back to schema defaults and
says so rather than presenting them as the preset's.

Row layout, from screenshots:

- The control column is anchored to the right edge at a fixed width. It
  had been packed left after a fixed label column, leaving the values
  stranded mid-container with dead space beside them.
- Units are no longer truncated to "mm o...". The cap fitted the common
  "mm" but not "mm or %" or "mm/s² or %".
- The "from file" tick moved ahead of the control it qualifies; it used to
  sit past the unit at the row's right edge, reading as unrelated.

Both the unit and the control keep fixed widths, and the tick's slot is
reserved on rows without one -- sizing any of them to content makes each
row's input land at a different x and the column comes out ragged.

Also fixes a field that could not be cleared: emptying a free-text input
dropped the key, so it snapped back to the baseline and retyping appended
to it ("0.42" + "0.5" = "0.420.5"). The number branch was fixed earlier;
the text branch -- coFloatOrPercent, coString, the vector types -- was
not, and the regression test used a number input so it never caught it.

Requires a sidecar built from orca-slicer-api 4b664b7 or later. Older
images 404 the endpoint, which is handled as the fallback above.
2026-08-09 11:02:03 +02:00
maziggy c384911f7c Sync 2026-08-09 10:00:53 +02:00
maziggy 2368445378 Changed layout 2026-08-08 16:51:19 +02:00
maziggy f0500578bd Edit the full print-parameter set from the slice dialog
Slicing from Bambuddy meant taking a process preset as-is; any change
meant a round trip through Bambu Studio. The slice dialog now carries
OrcaSlicer's full process tree -- pages, groups, labels, tooltips,
ranges and defaults extracted from the slicer's own sources.

Enable/disable rules are evaluated from the slicer's own enable_if
expressions via a recursive-descent interpreter (no eval, CSP), with
enum comparisons validated against each option's declared values.
Anything undecidable leaves the field editable rather than greyed.

Overrides apply after the source's support config (#1881) and the
designer's carried tweaks (#2622), so an explicit choice always wins;
an untouched panel sends the same request as before.

Adds slice_engine as a separate setting from preferred_slicer -- where
slicing runs is a different axis from which binary the sidecar drives.
Only the sidecar engine is registered, so no picker renders yet.
2026-08-08 16:33:41 +02:00
maziggy c8e5ecc23a chore(deps): clear every npm audit and pip-audit finding
Frontend:
- react-router/-dom 7.18.1 -> 7.18.2. The RSC-mode CSRF advisory was carried
  as a documented exception in the audit gate because its only fix was the
  8.3.0 major; upstream backported it, so the exemption lapsed on its own --
  an entry only holds while fixAvailable.isSemVerMajor is true. The allowlist
  is now empty; the machinery stays for the next one.
- dompurify 3.4.12 -> 3.4.13. Ships in the app, but the path is unreachable:
  no hooks registered, IN_PLACE never used.
- js-yaml override ^4.3.0 -> ^5.2.3 (fix not backported below 5.x, so a
  major) and nanoid override ^3.3.18. Both dev-only, via eslint and postcss.
  eslintrc calls only load(), on the legacy .eslintrc.yml path this repo does
  not use; eslint, vite build and 2861 frontend tests pass on it.

Backend:
- cryptography >=48.0.1 -> >=50.0.0, aiohttp >=3.14.0 -> >=3.14.3, pyopenssl
  >=26.3.0 -> >=26.4.0. CI resolves from scratch and was already installing
  the fixed releases; the floors cover the case CI does not, an existing venv
  where >= is satisfied and `pip install -r` upgrades nothing. pyOpenSSL has
  to move with cryptography -- each release caps it to a narrow window, so a
  stale pyOpenSSL pins cryptography below its own fix line.
2026-08-08 13:20:18 +02:00
maziggy 328bac450a Stop auto-drying re-arming into a threshold it can never reach (#2770)
An H2D armed five 12-hour drying cycles inside four hours, one of them six
seconds after the previous one ended, and none ran more than a couple of
hours.

Two things combine. The firmware ends a cycle when it decides the filament
is dry rather than when the clock runs out, and reports no fault doing it --
across this printer's history the run length tracks how wet the spools were,
from nearly the full 12 hours starting at 32% down to minutes once the unit
sat at 10-13%. That part is the AMS doing its job.

The loop is ours. An AMS reports higher relative humidity while it is warm
than once it has cooled: the same unit read 10-13% cold and 15-20% through
every cycle. With the threshold at 14% the reading at the moment a cycle
ended was always still above it, so the next 30-second pass armed another
12-hour cycle. Nothing counted, nothing waited, and it only stopped when the
box finally cooled enough to read 13%.

Auto-drying now waits 30 minutes after a cycle ends before arming another on
the same unit, and gives up on a unit after two consecutive cycles that
bring the reading no lower -- logging why and sending a new notification,
on by default because it reports that Bambuddy has stopped acting. Progress
is judged against the lowest reading any cycle on that unit has ended at,
not against the threshold, so a genuinely wet spool in a humid room coming
down 40-37-35 keeps drying however far it still is from the target;
comparing against the best so far rather than the previous end stops a
sensor wobbling by one point reading as progress every other cycle. The
suspension lifts by itself once the reading falls below the threshold.

Neither guard can stop a running cycle, and a cycle Bambuddy cut short for a
print, or that the user stopped by hand, is not counted against the unit --
so a farm that dries between queue jobs is unaffected. The threshold field
now warns below 20%, and every cycle end logs the unit's temperature and
humidity, which is what made this diagnosable.

The same bundle showed unrelated tasks failing with "database is locked",
each inside a 30.000-second Discord connect timeout. Alarms are raised from
inside the loop that records sensor history, at a point where the new rows
are added but not committed; the first read in the notification path flushed
them to satisfy itself, opening a write transaction, and the provider was
then contacted over the network with that transaction still open. SQLite
allows one writer and 30 seconds outlives the 15-second busy timeout, so
every other write in that window failed. The two reads that run before a
provider is contacted no longer flush the caller's pending work, and the
connect timeout is 5 seconds rather than 30 -- the body keeps the full 30,
so image uploads on a slow uplink are unaffected. SQLite only; Postgres has
no single-writer limit.
2026-08-08 12:40:17 +02:00
maziggy 04009a5c6a Post work PR #1448 2026-08-08 11:42:44 +02:00
MartinNYHC 406cf71149 Merge branch 'dev' into feature/billing 2026-08-08 10:56:09 +02:00
maziggy 595dc5844a Say which header blocked the 3D preview, instead of leaving the browser's page (#2787)
A reporter uploaded an STL, sliced it in Bambuddy, and got a frowny icon and
"<hostname> refused to connect" when previewing the sliced file -- while the
STL's own preview worked. That is Chrome's ERR_BLOCKED_BY_RESPONSE page, drawn
inside our layout shell, and the split between the two previews is where the
cause is: an STL or source 3MF renders in the page, a sliced file opens the
embedded G-code viewer, which is the only thing in Bambuddy that frames a
Bambuddy page (FileManagerPage.tsx:2472, GCodeViewerPage.tsx:47).

Our headers permit that frame -- frame-ancestors 'self' plus SAMEORIGIN on
everything under /gcode-viewer (main.py:7709) -- and the frame is same-origin,
so a refusal means a stricter header was added after we replied: a reverse
proxy, a security add-on, an auth gateway. None of which the user could see.
The browser drew its own page and nothing said what was refused, by whom, or
that the viewer opens perfectly well in a tab.

The frame cannot report this itself. A frame blocked by X-Frame-Options or
frame-ancestors still fires onLoad -- the browser commits an error document --
so there is no failure to catch. The page now asks for the same URL directly:
same-origin, so every response header is readable, and it goes through whatever
proxy the browser reaches Bambuddy by.

findFramingRefusal reads the verdict the way a browser does. frame-ancestors
wins outright when present, because CSP requires X-Frame-Options to be ignored
in that case -- reading both would blame a proxy-added DENY the browser never
consulted. Multiple CSP headers are intersected and fetch joins them into one
comma-separated string, so every frame-ancestors occurrence has to permit us,
not just the first; that is the shape a proxy appending its own policy to ours
actually takes. Failing that, a legacy header that is anything other than a
single SAMEORIGIN refuses us, including the conflicting "SAMEORIGIN, DENY" that
appears when a second copy is appended.

On refusal the frame is replaced with the header named verbatim, so an operator
can go and find the rule in their proxy config, and a link that opens the viewer
in its own tab -- a top-level page, which no framing header applies to. A
non-200 is reported the same way rather than as raw {"detail":"Not Found"}
inside the frame, which the startup-time warning at main.py:8120 already calls
out as easy to miss. A probe that cannot reach a verdict changes nothing: the
iframe stays, because guessing at a cause we cannot see is worse than the
browser's own page.

The working case is unaffected -- the iframe renders immediately as before and
the probe only ever replaces it.
2026-08-08 10:53:54 +02:00
maziggy bbcb7e54db Merge branch 'worktree-fix-2791-cursor-pointer' into dev 2026-08-08 10:16:15 +02:00
maziggy 82d6d98266 Restore the pointer cursor on interactive controls (#2791)
Hovering most of Bambuddy gave an arrow rather than a hand. Not everywhere,
which is what made it read as sloppiness rather than a bug: the update pill was
inert while the buttons beside it were fine, a bed or nozzle tile responded but
the history-graph button in its corner did not, and dropdowns went either way
with no pattern behind it.

The pattern was there. Tailwind v3's Preflight set `button { cursor: pointer }`.
v4 dropped it to match the browser default, which for a button is `default`.
Bambuddy has been on v4 since the frontend was built, and `src/index.css` never
had a base layer restoring it, so a button only looked clickable where someone
had written `cursor-pointer` by hand. 15 of 934 had. 0 of 149 selects, and 19
of 130 checkbox/radio inputs. The 233 ad-hoc `cursor-pointer` usages are why it
looked arbitrary instead of uniformly broken.

One `@layer base` rule now covers button, select, checkbox, radio, summary and
[role=button]. base sits below utilities, so `cursor-not-allowed` and the
`disabled:cursor-*` variants still win; the `:not(:disabled)` guard catches the
disabled controls that carry no such utility. Verified against the built bundle
rather than the source -- the rule lands inside @layer base, and
`.cursor-not-allowed` is emitted after it.

Click-outside backdrops are deliberately excluded. 90 of the 96 remaining
onClick divs are `fixed inset-0` overlays; a full-screen sheet advertising
itself as a button is worse than one that says nothing. Of the rest, 50 are
stopPropagation wrappers and 3 are the temperature tiles, which already set the
cursor through `statusControlClass` -- which is exactly why those tiles worked
while the button nested inside them did not. That left two real ones: Card, now
conditional on an onClick actually being passed, and the queue card, whose
existing `sm:cursor-default` kept the desktop intent.

Separately, from the same report. FilamentHoverCard draws the slot menu twice,
and the two paths had drifted into opposite orders: Configure above Assign Spool
on an empty slot, the reverse on a filled one, so the menu reshuffled itself
depending on whether the slot held filament. Both now lead with the spool
action. Tests assert the order on each path, so one can no longer move without
the other -- checked by reinstating the old order and confirming the empty-slot
test fails.

Those buttons also used justify-center, which centred each label independently
and left the icons in a ragged column; they are justify-start now. Their hover
was a 10% opacity step that was very hard to see, now 20%. And the favourites
star previews yellow on hover, suppressed when the user lacks archives:update.
2026-08-08 10:06:45 +02:00
maziggy 604fa44593 Explain Bambu Cloud's CAPTCHA challenge instead of repeating it (#2790)
A reporter tried to connect to Bambu Cloud and got "We need you to confirm you
are not a robot" as an error toast, with no CAPTCHA anywhere to answer and
nothing to click. That sentence is Bambu's, not ours. Their anti-abuse layer had
flagged the network and was answering the sign-in with HTTP 418 and a challenge
body: {"captchaId": "...", "error": "We need you to confirm you are not a
robot"}.

Bambuddy had no idea what that was. The reply is well-formed JSON, so
_detect_cloudflare_challenge -- which triggers on an unparseable body, CF
markers, 403+cf-mitigated or 503+cf-ray -- never fired on it, and login_request
fell through to its generic error path, which lifts data["message"] or
data["error"] out and hands it to the UI verbatim. The user was left to conclude
their password was wrong or that Bambuddy was broken. Four sign-in attempts
inside eighteen seconds appear in their log, each one more evidence for the
thing that had flagged them.

is_captcha_challenge matches on the 418 status plus a challenge marker in the
body -- captchaId is the reliable one, the wording is matched too because Bambu
has shipped it under more than one phrasing. A bare 418 with no marker is
has shipped it under more than one phrasing. A bare 418 with no marker is
deliberately NOT reported as a CAPTCHA: telling someone to solve a challenge
that was never offered is the exact confusion this issue is about.

login_request, verify_code and verify_totp now return reason="captcha" with an
explanation covering the three things the reporter had no way to find out: the
credentials are not the problem, the block is keyed to the public IP address
rather than the account, and it clears by itself within a few hours.

Sign-in requests are then held back for 300s so Bambuddy stops deepening the
block. Keyed per origin, not per service: TOTP verification posts to
bambulab.com while everything else posts to api.bambulab.com, and a challenge
seen on one must not strand somebody halfway through a two-factor sign-in on the
other. Entries expire on read, so the map cannot grow past one per region. The
token endpoint is deliberately left ungated -- it is the way out.

The UI shows a persistent panel rather than a toast. A toast names a problem the
user cannot act on and then vanishes; this one stays put and carries a one-click
route to "Use access token instead", which is the only thing that works while
the challenge lasts, since that path does not touch the challenged endpoint.

MakerWorld meets the same challenge from the same edge and now shares the
detection. It used to require the literal word "robot" in the error text and
reported any other wording as an unexplained block.

The System Health scanner gets a bambu-cloud-captcha signature. The reporter's
bundle came back with zero findings while their log was full of the failure.

Its advice for a failed FTPS handshake was corrected at the same time: it still
blamed firewalls and outdated firmware, which the #2780 investigation ruled out
last release -- it is the printer's own file service wedging, and the fix is to
restart the printer. The wiki said so already; the health panel did not.
2026-08-08 10:04:55 +02:00
maziggy 91acac2b35 Stop retrying a printer whose FTPS handshake fails, and name the cause (#2780)
Two printers went on printing while every archive they produced held nothing
but a filename. Bambuddy opened port 990, the printer accepted the connection
and answered with something that was not TLS, and connect() logged a warning
and returned False -- indistinguishable, to every caller, from "the file is
not at this path". So the 3MF lookup walked all six filename variants across
five directories with four retries each, the cover endpoint ran its own
sixteen-path sweep, and the timelapse scan added four more, all against a
sixteen-path sweep, and the timelapse scan added four more, all against a
printer that could not have answered any of them. One reporter's log carried
1813 identical handshake failures, another's 3511.

The evidence says this is the printer's own file service getting stuck, not a
model, firmware or TLS-configuration problem. In #2780's bundle the same two
printers ran clean from 22 July to 4 August and failed again from the 5th; a
second bundle shows an X2D serving files for five days, flipping on 19 July,
then failing every connection for eight days with zero successes. The same
models and firmware appear in roughly twenty other bundles with no occurrences
at all. Both bundles show it happening with cap_tls_v1_2 in effect -- the X2D
and H2C entries in ftp_profiles were added on analogy with P2S to fix exactly
this symptom, and the reporter's own debug line proves they do not.

An ssl.SSLError from connect() now opens a five-minute cool-off for that
printer. Subsequent connects return False without touching the network, so a
wedged printer is contacted twice an hour instead of hundreds of times a
minute, and the single warning that is logged names the remedy. The cool-off
is dropped on expiry rather than kept, so the map holds one key per currently
wedged printer. ftps_handshake_blocked() lets the sweeps stop: the 3MF lookup
abandons the remaining paths and skips the directory-walk fallback, the cover
endpoint returns 503 naming the file service instead of a 404 that reads as
"this print has no thumbnail", and the timelapse scan separates 503 (cannot
reach the printer) from 404 (no timelapse directory) -- one 500 used to cover
both, which is what the reporter hit when reproducing.

The Connection Diagnostic completed a bare TCP connect to 990, which is why it
reported the port green throughout: the port is open, it is what is behind it
that is broken. It now completes a real implicit-TLS handshake using the
model's own ftp_profiles cap, so a pass means the FTP client would also get
through. An open port that cannot negotiate reports warn with reason no_tls,
selecting a new message in all 13 locales that points at a printer restart
rather than at the firewall. No login is attempted, so this stays valid in the
pre-save Add Printer flow.

The cool-off tests run against a real socket that accepts on 990 and replies
with a plaintext FTP banner, reproducing WRONG_VERSION_NUMBER rather than
mocking ssl. The autouse fixture clearing _mode_cache now clears the cool-off
map too -- every test here talks to 127.0.0.1, so one left behind would make
the next test's connect() a no-op.
2026-08-08 09:00:03 +02:00