Commit Graph
4017 Commits
Author SHA1 Message Date
maziggy 019fb1c8af 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-17 15:46:06 +02:00
maziggy a0778f931b 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-17 15:45:32 +02:00
maziggy 88feb69bc3 Stop auto K-profile calibration leaving an archive behind
With flow dynamics calibration on, the printer lays down a
    pressure-advance line before the print itself and announces it over MQTT
    through the same print-start event a real print uses. Bambuddy archived
    it: a row named auto_pa_line_calib_mode marked as having no 3MF, sitting
    in among the user's actual prints, with a Print Started and a Print
    Completed notification for each one.

    The printer's other internal jobs were already skipped, but only by the
    /usr/ path they carry -- bed levelling reports as
    /usr/etc/print/auto_cali_for_user.gcode. The pressure-advance line
    carries no path at all. It arrives as a bare subtask name, so a rule
    that only ever looked at the filename could not see it. Falling past the
    guard it reached the no-3MF fallback, where print_name is subtask_name
    or filename, and named the row after the calibration.

    Internal jobs are now recognised by name as well as by path, from either
    field, in one place both callbacks share. The match is exact after
    normalising away the directory, one print-file suffix and case, rather
    than a prefix or substring rule: "auto" and "calib" are ordinary words in
    a user's own filenames, and a rule loose enough to catch some unnamed
    future calibration would quietly swallow somebody's print.

    The completion is suppressed too, and that half matters more than the
    noise. With no archive to close, the completion falls into the
    no-archive notification path -- which attributes an unmatched completion
    to any queue item the printer finished in the last five minutes and
    emails its owner. This calibration runs alongside a real print, so
    silencing only the start would have told that print's owner their job
    was done, early, and again for real later. The guard sits inside the
    no-archive branch, so the plate-clear gate, the queue reconciliation and
    the SD-card cleanup all still run; only the notification is skipped.

    Skipping the run early also drops the FTP sweep that preceded the
    fallback -- six candidate names across five directories with retries,
    around a hundred connections looking for a file that cannot exist, aimed
    at a printer that is in the middle of calibrating.

    auto_cali_for_user no longer sends a Print Started notification either.
    It is the same event about the same kind of job, and the archive was
    never the only thing wrong with treating it as a user's print.
2026-08-17 15:45:07 +02:00
maziggy 7354650835 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-17 15:44:47 +02:00
maziggy 85dc768b80 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-17 15:44:18 +02:00
maziggy 64a1defa7a Stop the AMS temperature alert firing for heat the user asked for (#1802)
The alert compares against ams_temp_fair, the same threshold that colours
    the printer card, which defaults to 35C. Drying deliberately runs at 45C
    for PLA, 65C for PETG and up to 85C on an AMS-HT, and the alert repeats
    once an hour for as long as the condition holds, so a twelve-hour dry
    sent twelve notifications about a temperature the user chose. It then
    kept sending them while the unit cooled back down, which is the half the
    reporter confirmed on an AMS 2 Pro and an H2C.

    Dispatch now consults the drying state the firmware already reports.
    dry_time alone is not enough: it reads 0 through the cooling phase that
    closes a cycle, so dry_status -- info bits 4-7, already parsed for the
    drying-complete edge -- carries the rest. That constant moves out of
    bambu_mqtt into a leaf util rather than being duplicated; drying_preflight
    would have been the natural home, but it imports printer_manager, which
    imports bambu_mqtt, and bambu_mqtt is one of the callers.

    The cool-down afterwards is held by a latch released as soon as the unit
    reads back at or below the threshold, rather than after a fixed delay, so
    a 65C cycle in a cold basement and a 45C one in a warm room each get the
    time they actually need. A two-hour cap bounds the one case the latch
    cannot resolve on its own -- a unit that never returns below the
    threshold -- and since such a unit would have been alarming with no
    drying involved, releasing there restores the ordinary behaviour instead
    of inventing a new alert.

    Two exclusions are deliberate. Humidity is untouched, because during
    drying that reading falling is the whole point. And dry_status 6,
    HeatOutOfControl, is kept out of the active set: an AMS that has lost
    thermal control is exactly when the alert should still arrive, so it must
    never read as expected heat.

    A cycle plus its cool-down outlasts a restart, so the latch is a settings
    row rather than a dict beside _ams_alarm_cooldown -- the internal
    timestamp-row pattern support.py already uses. It is read once per pass
    and written back only when a unit changed it. Stamps ahead of now are
    clamped on read, since a box whose clock jumps backwards writes them and
    suppression is measured as now minus the stamp; without the clamp the cap
    would measure from a moment that has not happened yet and hold the alert
    quiet for the skew on top of it.

    No new setting. The reporter was offered the opt-out checkbox they asked
    for and said they would not want it if the alert simply never fired
    during drying.
2026-08-17 15:43:42 +02:00
maziggy f10f485801 Updated README 2026-08-17 14:02:55 +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 9c93884397 Stop auto K-profile calibration leaving an archive behind
With flow dynamics calibration on, the printer lays down a
pressure-advance line before the print itself and announces it over MQTT
through the same print-start event a real print uses. Bambuddy archived
it: a row named auto_pa_line_calib_mode marked as having no 3MF, sitting
in among the user's actual prints, with a Print Started and a Print
Completed notification for each one.

The printer's other internal jobs were already skipped, but only by the
/usr/ path they carry -- bed levelling reports as
/usr/etc/print/auto_cali_for_user.gcode. The pressure-advance line
carries no path at all. It arrives as a bare subtask name, so a rule
that only ever looked at the filename could not see it. Falling past the
guard it reached the no-3MF fallback, where print_name is subtask_name
or filename, and named the row after the calibration.

Internal jobs are now recognised by name as well as by path, from either
field, in one place both callbacks share. The match is exact after
normalising away the directory, one print-file suffix and case, rather
than a prefix or substring rule: "auto" and "calib" are ordinary words in
a user's own filenames, and a rule loose enough to catch some unnamed
future calibration would quietly swallow somebody's print.

The completion is suppressed too, and that half matters more than the
noise. With no archive to close, the completion falls into the
no-archive notification path -- which attributes an unmatched completion
to any queue item the printer finished in the last five minutes and
emails its owner. This calibration runs alongside a real print, so
silencing only the start would have told that print's owner their job
was done, early, and again for real later. The guard sits inside the
no-archive branch, so the plate-clear gate, the queue reconciliation and
the SD-card cleanup all still run; only the notification is skipped.

Skipping the run early also drops the FTP sweep that preceded the
fallback -- six candidate names across five directories with retries,
around a hundred connections looking for a file that cannot exist, aimed
at a printer that is in the middle of calibrating.

auto_cali_for_user no longer sends a Print Started notification either.
It is the same event about the same kind of job, and the archive was
never the only thing wrong with treating it as a user's print.
2026-08-16 14:01:33 +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 7a9b4921bd Stop the AMS temperature alert firing for heat the user asked for (#1802)
The alert compares against ams_temp_fair, the same threshold that colours
the printer card, which defaults to 35C. Drying deliberately runs at 45C
for PLA, 65C for PETG and up to 85C on an AMS-HT, and the alert repeats
once an hour for as long as the condition holds, so a twelve-hour dry
sent twelve notifications about a temperature the user chose. It then
kept sending them while the unit cooled back down, which is the half the
reporter confirmed on an AMS 2 Pro and an H2C.

Dispatch now consults the drying state the firmware already reports.
dry_time alone is not enough: it reads 0 through the cooling phase that
closes a cycle, so dry_status -- info bits 4-7, already parsed for the
drying-complete edge -- carries the rest. That constant moves out of
bambu_mqtt into a leaf util rather than being duplicated; drying_preflight
would have been the natural home, but it imports printer_manager, which
imports bambu_mqtt, and bambu_mqtt is one of the callers.

The cool-down afterwards is held by a latch released as soon as the unit
reads back at or below the threshold, rather than after a fixed delay, so
a 65C cycle in a cold basement and a 45C one in a warm room each get the
time they actually need. A two-hour cap bounds the one case the latch
cannot resolve on its own -- a unit that never returns below the
threshold -- and since such a unit would have been alarming with no
drying involved, releasing there restores the ordinary behaviour instead
of inventing a new alert.

Two exclusions are deliberate. Humidity is untouched, because during
drying that reading falling is the whole point. And dry_status 6,
HeatOutOfControl, is kept out of the active set: an AMS that has lost
thermal control is exactly when the alert should still arrive, so it must
never read as expected heat.

A cycle plus its cool-down outlasts a restart, so the latch is a settings
row rather than a dict beside _ams_alarm_cooldown -- the internal
timestamp-row pattern support.py already uses. It is read once per pass
and written back only when a unit changed it. Stamps ahead of now are
clamped on read, since a box whose clock jumps backwards writes them and
suppression is measured as now minus the stamp; without the clamp the cap
would measure from a moment that has not happened yet and hold the alert
quiet for the skew on top of it.

No new setting. The reporter was offered the opt-out checkbox they asked
for and said they would not want it if the alert simply never fired
during drying.
2026-08-16 11:45:46 +02:00
maziggy 145c7d5f57 Pin Trivy to a release that still exists (#2844)
The scan pinned Trivy v0.69.1, which aquasecurity have since deleted --
retained releases now run v0.74.0 down to v0.69.2 and then jump back to
v0.26.0. The tag survives, so setup-trivy resolves it, reports "found
version: 0.69.1" and then exits 1 with no asset to fetch.

This repository did not notice because the binary was coming back from
the Actions cache on every run, which skips the download. Forks have no
such cache, which is where it was reported from -- and the same failure
was due here the first time that entry went cold.

Both scans move to trivy-action v0.36.0 and Trivy v0.74.0; every input
they pass is still declared in the new action. The comment records that
this pin has to be bumped rather than left, and that a green run is not
evidence it still resolves.

The config scan is clean on v0.74.0, so the bump adds no new
misconfiguration alerts.
2026-08-16 10:17:02 +02:00
maziggy c839841063 Pin Trivy to a release that still exists (#2844)
The scan pinned Trivy v0.69.1, which aquasecurity have since deleted --
retained releases now run v0.74.0 down to v0.69.2 and then jump back to
v0.26.0. The tag survives, so setup-trivy resolves it, reports "found
version: 0.69.1" and then exits 1 with no asset to fetch.

This repository did not notice because the binary was coming back from
the Actions cache on every run, which skips the download. Forks have no
such cache, which is where it was reported from -- and the same failure
was due here the first time that entry went cold.

Both scans move to trivy-action v0.36.0 and Trivy v0.74.0; every input
they pass is still declared in the new action. The comment records that
this pin has to be bumped rather than left, and that a green run is not
evidence it still resolves.

The config scan is clean on v0.74.0, so the bump adds no new
misconfiguration alerts.
2026-08-16 10:16:21 +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
maziggy 74c876a588 Housekeeping v1.2.5.3 2026-08-15 16:15:22 +02:00
MartinNYHC caea50f2ca Merge pull request #2840 from maziggy/1.2.5.3
**Bambuddy 1.2.5.3**

**What this is**

A feature-and-fix release on top of 1.2.5.2, with four things carrying most of it: the slice dialog gains OrcaSlicer's full process-parameter set, the G-code and model previews are rebuilt on the slicer's own renderer, the H2C's six-hotend nozzle rack can finally be aimed rather than guessed at, and selected categories can be restored from a Git backup commit. Around it are 39 fixes, a heavy run of them on AMS drying, slicing and the queue. Five of the features come from outside contributors. No breaking changes. Several table and column additions are applied automatically on both SQLite and PostgreSQL.

If you are coming from 1.2.5 or earlier, read the 1.2.5 release notes first — all of its upgrade callouts apply to you as well.

**Docker**

docker compose pull
docker compose up -d

**Native install — recommended path**

sudo BRANCH=main /opt/bambuddy/install/update.sh

**Native install — manual path**

sudo systemctl stop bambuddy
cd /opt/bambuddy
sudo -u bambuddy git fetch --prune --tags --force origin
sudo -u bambuddy git checkout main
sudo -u bambuddy git reset --hard origin/main
sudo /opt/bambuddy/venv/bin/pip install -r requirements.txt
cd frontend && sudo npm i
sudo systemctl start bambuddy

**Windows install**

Download bambuddy-1.2.5.3-windows-x64-setup.exe from this release page (or the unversioned bambuddy-windows-x64-setup.exe alias). Existing Windows installs upgrade in place via the in-app Install Update flow.

**New**

- Billing and cost centres, with per-print charging and budgets (#1448, contributor @behrinml) — Bambuddy could tell you what a print cost but could not hold anyone to it. There is now a finance layer behind the print flow: cost centres with budgets, per-user wallets, and a transaction for every print. A cost centre can be picked in the print dialog, travels with the queue item and the archive, and is reserved against before the job is dispatched rather than after it finishes, so a print that would take a budget past its limit does not start. Charges settle on real filament usage at completion, and a print that aborts part-way is charged for the part that ran instead of being written off or billed in full. Every user gets a personal cost centre and wallet on first sign-in, including the first sign-in through LDAP, so a directory-backed install does not need them created by hand. A monthly reset day and timezone decide when budgets roll over. The whole feature is behind a billing toggle and is off by default, and an optional printer kill switch stops dispatch entirely once a budget is exhausted. Cost-centre management has its own permissions rather than riding on the settings ones, so a farm can let someone spend against a budget without letting them change it. Ships with a Finance page, migrations for both SQLite and PostgreSQL, and translations in all locales.

- One queue item, several printer models — whichever frees up first (#671, reporter @brainomite; also delivers most of #2570, reporter @NeighborGeek) — With an H2S and an H2C, a job you don't care which machine runs still had to be queued twice: the two printers need different slices, a queue item held exactly one file, and "any H2S" and "any H2C" were separate jobs competing for the same plastic. Whichever started first, you deleted the other by hand. Select both sliced files in the File Manager and press **Print** and you now get **one** queue item carrying both — the scheduler walks them in the order you arranged and takes the first whose model has an idle printer. The many-to-many never leaves the scheduler's selection loop: the moment a candidate wins, its file, plate and nozzle mapping are folded onto the queue row, so the upload, archive creation, print history and reprint all see an ordinary single-file job and behave exactly as they always have. Order is yours to set, because "both are free right now" has to resolve the same way every time rather than following whichever match the matcher happened to see first. Candidates are otherwise tried least-attempted first, so a printer that accepts the file and never starts hands the job to the other machine on the next lap instead of spending the item's whole retry budget on the one - **Billing and cost centres, with per-print charging and budgets (#1448, contributor @behrinml)** — Bambuddy could tell you what a print cost but could not hold anyone to it. There is now a finance layer behind the print flow: cost centres with budgets, per-user wallets, and a transaction for every print. A cost centre can be picked in the print dialog, travels with the queue item and the archive, and is reserved against before the job is dispatched rather than after it finishes, so a print that would take a budget past its limit does not start. Charges settle on real filament usage at completion, and a print that aborts part-way is charged for the part that ran instead of being written off or billed in full. Every user gets a personal cost centre and wallet on first sign-in, including the first sign-in through LDAP, so a directory-backed install does not need them created by hand. A monthly reset day and timezone decide when budgets roll over. The whole feature is behind a billing toggle and is off by default, and an optional printer kill switch stops dispatch entirely once a budget is exhausted. Cost-centre management has its own permissions rather than riding on the settings ones, so a farm can let someone spend against a budget without letting them change it. Ships with a Finance page, migrations for both SQLite and PostgreSQL, and translations in all locales.

- One queue item, several printer models — whichever frees up first (#671, reporter @brainomite; also delivers most of #2570, reporter @NeighborGeek) — With an H2S and an H2C, a job you don't care which machine runs still had to be queued twice: the two printers need different slices, a queue item held exactly one file, and "any H2S" and "any H2C" were separate jobs competing for the same plastic. Whichever started first, you deleted the other by hand. Select both sliced files in the File Manager and press **Print** and you now get **one** queue item carrying both — the scheduler walks them in the order you arranged and takes the first whose model has an idle printer. The many-to-many never leaves the scheduler's selection loop: the moment a candidate wins, its file, plate and nozzle mapping are folded onto the queue row, so the upload, archive creation, print history and reprint all see an ordinary single-file job and behave exactly as they always have. Order is yours to set, because "both are free right now" has to resolve the same way every time rather than following whichever match the matcher happened to see first. Candidates are otherwise tried least-attempted first, so a printer that accepts the file and never starts hands the job to the other machine on the next lap instead of spending the item's whole retry budget on the one that is wedged. The set is validated as a set: one file per printer model (two slices for the same machine are not alternatives, and picking between them arbitrarily would look like a bug the first time it chose your draft profile), every file gated against the model it is offered as, and at least one model that actually has a printer — grouping the H2C slice before the H2C arrives is fine, queueing a job nothing can ever run is not. A cross-model item deliberately holds no file of its own, so deleting one alternative leaves the job and its sibling intact; deleting or trashing every candidate holds it with an explanation instead of failing deep in the upload. Filament overrides offer everything loaded across **all** the candidate models rather than just the first — a spool loaded on only one of them is still a legitimate choice, it simply narrows which candidates can match — while AMS slot mapping is absent exactly as it is on an ordinary "Any [model]" job, because no printer has been picked yet and the scheduler derives the mapping against whichever one it takes. In the queue the job reads **Any H2D / X1C**, naming every model it is waiting on rather than filing itself under one it may never run on, and its waiting reason is given per model (`H2D: Busy: H2D-1; X1C: No matching material/color`), collapsing to a plain busy message — and no notification — when every model is merely printing. The alternatives are fixed once queued: the schedule, quantity and print options stay editable, but assigning a specific printer or narrowing to one model is refused by both the dialog and the API, since an item holding alternatives *and* a printer would dispatch down the fixed-printer path with no file to send. Cancel and re-queue to change the set. Files can also be grouped permanently with **Group as versions**, after which printing any one of them offers the others without re-selecting — this is the grouping and the print-time file matching asked for in #2570, minus its nested File Manager listing. An existing library arrives with its groups already built, from slice provenance Bambuddy has been recording since the Slice button shipped and had never read back. Translated in all locales; wiki updated. Covered by backend and frontend tests.

- Batch orders: a quantity per plate, and an order that knows what it still owes (#342, reporter @cimdDev) — Printing a multi-plate file in different quantities per plate meant queueing each plate separately and tracking the counts yourself, because one shared **Quantity** field cannot say "plate 1 once, plate 2 twice, plate 3 three times". Each selected plate of a multi-plate file now carries its own quantity, and the submission becomes a **batch order** on a new **Batches** tab of the Print Queue page. What that buys is the distinction the old batch could not express: the order records how many runs of each plate were *wanted*, separately from what was queued. A run that fails, is cancelled or is skipped does not satisfy a target, so the order goes on saying it owes a print instead of quietly under-delivering — and a **Queue remaining** action re-queues exactly what is missing, for the whole order or one plate. Those new items are copied from the most recent run of that plate, so they inherit the printer or model target, AMS mapping, filament overrides and print options already chosen, and they are appended to the end of the relevant printer's queue rather than jumping ahead of work already lined up. Orders show progress against target, per-plate breakdown, and cost. Cost is measured rather than estimated: each finished run's material and energy are attributed through the queue item that produced them, so an unrelated reprint of the same file never lands in an order's total, and a multi-plate order gets each plate's own cost rather than the whole file's. Before any run has completed there is no honest figure, so cost reads as unknown instead of a fabricated `0.00`. An order becomes **completed** the moment its last run lands rather than whenever someone next opens the page, and raising a target on a finished order reopens it. Targets stay editable while the order runs, since production requirements change mid-job. The default flow is unchanged — creating an order still queues all of it immediately, and a single-plate file still has one Quantity field. Batches created before this release keep working and are labelled **Grouping only**: they only ever knew what was queued, not what was wanted, so they report progress but have nothing to dispatch. They also get closed out on the first start after upgrading — `completed` was not a reachable status before now, so every batch created since grouping shipped is still marked active however long ago its last print finished, and without that pass the new tab would open on months of accumulated history. Only batches with nothing queued or printing are touched: those whose runs all completed become completed, and groupings whose items were all cancelled become cancelled, which is what they are — calling them completed would claim output that never happened. Batches with neither queue items nor targets are no longer listed at all; those are empty shells left behind when a grouping's items were deleted with their source archive. Translated in all locales; wiki upd
ated. Covered by backend and frontend tests.

- Nest projects under a master project and roll their figures up (#1264) — Projects were flat. The `parent_id` column and the sub-project list already existed but nothing outside the API could set a parent, and a master project's statistics only ever covered its own prints. The project dialog now has a parent picker, and a project with sub-projects gets a second card covering the whole tree: jobs, parts, time, filament, cost, and progress against every target in the tree added together. That card is deliberately separate from the project's own stats, which keep their existing meaning — widening them would have restated the figures of anyone who had already nested projects over the API. Each listed sub-project carries its own branch's roll-up, so the rows add up to the card above them. On the Projects page a sub-project is drawn inside its parent's group rather than as another card in the grid, because two cards columns apart cannot show that they belong together whatever the caption says. Translated in all locales; wiki updated.

- Keep the chamber warm between prints and skip a soak that is not needed (#2727, contributor @ticfinack) — 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. While a printer sits in FINISH waiting for plate-clear and the next queued item needs chamber heat, the bed is held hot so the chamber does not cool during the bed-clearing window. The bed is the chamber's heating element here rather than a print surface, so the hold runs at the new **Keep-warm bed temperature** (90 °C by default, which also satisfies the aftermarket chamber heaters that trigger off a bed threshold) and rises to the item's own bed temperature when that is higher. It is gated on the keep-warm setting, on plate-clear being required, and on the next item actually needing the heat, and it is capped by a maximum duration so a queue that stalls does not leave a bed hot indefinitely. A follow-up closed the two dispatch exits that could drop the hold without releasing it — a claim failure returns before the rollback opens, and a vanished row left the printer id unset, which the rollback guards on — either of which left a bed hot with nothing tracking it, reachable whenever a cancel or delete landed between selection and the claim.
- Restore selected categories from a Git backup commit — Bambuddy has pushed backups to GitHub, GitLab, Gitea and Forgejo for a while; now it can read one back. Pick a commit, preview what it holds, choose which categories to restore (#2656, contributor @jmoore-skild).
- Edit the full print-parameter set from the slice dialog — the dialog now carries OrcaSlicer's own process tree, with its pages, groups, tooltips and ranges, and evaluates the slicer's own enable/disable rules. Slicing no longer means taking a preset exactly as it comes.
- A new G-code and model preview — the vendored PrettyGCode iframe is gone, replaced by libvgcode, the renderer OrcaSlicer draws its own preview with. Real occlusion instead of screen-space lines, and it is themed and translated like the rest of the app. The model preview was rebuilt alongside it, with proper framing and lighting.
- Choose which rack nozzle each filament prints from on an H2C (#1784) — the Vortek rack holds six hotends and the choice is not recorded in the 3MF, so plates went out with no assignment and the printer picked for itself. Every rack-bound filament now has a position picker showing all six and the nozzle each holds.
- Home Assistant sensors on the printer card, with an optional print interlock (#1148, reporter @bsaunder; #448, reporter @baudneo) — surface HA entities on the card, and optionally block a print from starting when one of them says not to.
- The Print Log shows how much filament a run used, and lets you choose its columns (#2636, reporter @ajbastien).
- Auto-orient and auto-arrange when slicing server-side (#2548, reporter @ceokingcobra).
- Open a File Manager model in your desktop slicer, and pick which one from the 3D preview (#2725, contributor @pascalheidmann).
- Server-side slicing on an ARM64 host (#1900, contributor Felix Reissmann) — an override pins the sidecar to amd64 and runs it under emulation, with the binfmt requirement and the three-to-six-times slowdown stated up front. A separate x86_64 machine is still the recommendation.
- Temperatures on the streaming overlay, and a builder for its URL (#1422, reporter @SMAW).
- Open a multi-plate sliced file on the plate you asked for — the viewer gains a plate switcher and keeps the choice in its URL, and filament colours follow it instead of always coming from the first plate.
- Show the plug that actually powers the printer in the card's Power row (#2830) — which plug filled that row was previously decided by nothing at all, so it could land on an enclosure fan and offer to switch the printer off by cutting it.
- The Printers page remembers its status and location filters (#2833) — the only two preferences on that page that were not persisted.
- The external spool can be hidden from the printer card (#1782, reporter @Arn0uDz).
- Uploaded archives can be named after the filename you sent (#2610, contributor @Person2099).
- The chamber temperature limit is raised from 60 to 65 °C (reported on Discord).
- The Spool Inventory can be sorted by colour rather than by colour name (#2729, reporter @macwhiz).
- API keys can read and run slicer pipelines (#1425) — every pipeline endpoint answered 403 to a key whatever scopes it carried. Running a pipeline requires the queue and library-manage flags together, and a 403 now names every flag the key is short of.
- API clients can resolve user ids to names (#1894) — archives, the queue and statistics report ownership as a numeric id, and nothing let a key discover whose id was whose without an admin listing.
- Forgejo tokens scoped to a single repository are accepted (#2775) — a repository-scoped v15 token was rejected for failing a user lookup it does not need to pass.
- The MQTT debug log records the commands sent to a printer, not only what it reports back.
- Queue items created from the Library's bulk Add to queue and through the webhook API now record who created them, so own-work permissions can see them.

**Fixes**

**H2C and multi-nozzle:**

- An H2C levelled on one hotend and printed with another, several millimetres above the plate (#2800). A print command names the rack nozzle by physical position rather than by extruder index, and Bambuddy only ever had that position for jobs arriving through the Virtual Printer — everything else omitted the field and let the firmware choose. Two hardware-derived values were then corrected by the reporter's own A/B on real hardware, and the fixed/rack carriage assignment turned out to be inverted.
- An H2C refused a multi-colour print outright with HMS 0500-4047, a hotend mismatch: on a rack machine the slicer writes a filament group per nozzle rather than per carriage, so a three-group plate lost a filament against a two-entry map.
- The H2C nozzle rack card sizes itself to its contents instead of claiming several hundred pixels and leaving them empty, numbers its slots 1 to 6, and scales its chips with the card size.

**AMS, drying and filament:**

- AMS drying was torn down and restarted once per scheduler tick while a plate sat unacknowledged — about 2000 state changes over ten days, with no cycle ever running long enough to remove moisture, and hand-started cycles on other units of the same printer torn down with them (#2801).
- Auto-drying re-armed into a threshold it could never reach (#2770) — an AMS reads a higher humidity warm than cold, so the reading at the moment a cycle ended always armed the next one. Five twelve-hour cycles inside four hours.
- A drying cycle the printer abandons now says so, and says what the printer reported (#2770, reporter @tchavei).
- A drying cycle no longer reports itself finished a minute after it starts (#2759).
- The drying badge invented a temperature on a uniformly loaded AMS, showing the spools' RFID recommendation rather than the temperature that was picked (#2759 follow-up).
- The drying popover no longer starts a cycle under a material you did not pick (#2774).
- The nearest filament colour is picked instead of the first eligible one in tray order, and the ranking is perceptual — RGB distance overweights blue badly enough to invert the answer (#2804, #2823, contributor @grolmus). Filament type matching also agrees between the interface and the scheduler now.
- Spoolman no longer charges a Bambu Studio print to the wrong spool (#2768).
- "Any X2D" works on a printer that feeds from external spools instead of an AMS (#2771, reporter @Nick-C130).
- AMS Filament Backup no longer charges a whole print to the substitute spool — everything needed to split the filament across the trays it actually came from lived only in memory, so a print that outlived a restart lost it.
- The print dialog pools AMS Filament Backup spools in its filament check, instead of refusing a job against one slot while an identical full spool sat in the next one.
- A refused AMS filament setting now says so in the log (#2756, reporter @Jostxxl).
- Configuring an AMS slot shows up on the printer card straight away, without a page reload.

**Queue and dispatch:**

- A completion for one print closed another print's queue item, marking it completed while the printer was still working and stranding the rest of its batch (#2829). The check that fixes it also had to learn that the printer rewrites the name it echoes back, which had left queues stopped until someone cancelled by hand.
- Deleting a library file destroyed the jobs queued against it — silently on PostgreSQL, and as "Library file not found" days later on SQLite (#2819).
- A library-backed job was dispatched onto a spool that could not finish it: 20.5 g needed, 9 g loaded, no deficit reported (#2779). Slicer pipeline jobs and everything from the Library's bulk add were affected.
- A job queued to a printer class never powered a printer on, while the same file pinned to a specific printer did (#2786).
- A print that never starts now says AMS drying was running, instead of blaming the SD card (#2758).

**Slicing and previews:**

- A slice failed on a model whose name contains a slash — a MakerWorld title arrives with its punctuation and was used verbatim as a folder name (#2832).
- A slice of a file on a network share was written to managed storage instead, showing up in the right folder in the interface and never reaching the share (#2810).
- A 3MF no longer switches off supports its process preset turned on (#2820) — the carry that lets a project's support configuration survive was running in both directions.
- An oversized model reads as an oversized model, not a slicer crash (#2802). The advice to update the sidecar was wrong too: it named a bare compose pull, which skips the profile-gated sidecar silently.
- A preview slice no longer gives up on custom G-code the sidecar cannot parse — the silent fallback to guessing from painted faces was dropping a whole filament slot.
- Bundled presets resolved their start G-code to a generic block: all 56 instantiable BBL machine presets, producing a print that heats the bed, moves the toolhead and extrudes nothing.
- The process-settings panel shows the preset's own values instead of the compiled-in defaults, and names which of four causes applied when it cannot read them.
- Server-side slicing is no longer offered for STEP files, which neither slicer can load from its command line. Open in Slicer still hands them to the desktop application.

**Printers, archives and connection:**

- Archives arrived empty from printers whose file service could not answer (#2780). Two faults: H2-series and P2S firmware can keep the sliced file on internal storage, which port 990 cannot reach — the print command says which, and we discarded it and swept anyway, around 110 doomed connections per print. And a printer whose FTPS handshake wedges now gets a five-minute cool-off instead of being retried hundreds of times a minute; one reporter's log carried 1813 identical failures, another's 3511.
- Photos and filament accounting on archives that arrive without a 3MF, which on an H2S is any job started from the printer's own library (#1820). Photos were written in one place and looked for in another, and the fallback that stands in for a missing 3MF could charge nothing without a word.
- The printer card thumbnail is back after navigating away and returning (#2826) — a cache hit raced the mount effect, which is why it reproduced every time for the reporter and never here.
- Live updates stopped arriving while the Bambuddy tab was in the background (#2754, reporter @mic4rd).
- A print stage Bambuddy cannot name is now logged at INFO, once per stage number per session, with the context needed to name it afterwards.

**Interface:**

- Interactive controls show a pointer cursor again (#2791) — Tailwind v4 dropped the base rule and only 15 of 934 buttons had it written by hand.
- The Spool Inventory header no longer scrolls the whole page sideways on a phone (#2813).
- The Virtual Printer card header wraps instead of painting outside its border (#2808).
- A refused frame no longer leaves the browser's own error page inside Bambuddy's layout, and says which header blocked it (#2787).
- Form controls follow the page's colour scheme — steppers, calendar buttons, dropdowns and scrollbars were drawn light on every theme.
- The L and XL printer cards scale their text and icons, not just their width (#1848, reporter @misterff1).
- The bug-report button no longer covers the controls in the bottom-right corner (#2750, reporter @goodjaltman).
- Error and warning toasts stay up twice as long.
- The Print Log is reachable again once you have no archives, and its cost and energy figures reach the browser at all.
- The Docker update command is copyable, and knows where your compose file lives (#2664, reporter @pchulpjoost).
- The Slicer Bundles notice is gone from Settings — bundle import was withdrawn in 0.2.5 and the panel had been sitting there since, unactionable.

**Login, deployment and integrations:**

- LDAP login works again on directories that define no POSIX group class (#2769, reporter @peterskotte).
- A hand-written systemd service left the Virtual Printer unable to start, with nothing obvious to blame (#2549, reporter @Ru3ck3).
- Bambu Cloud's anti-robot challenge is explained instead of repeated back as a bare error with nothing to click (#2790).
- Home Assistant notifications carry nested data through unchanged (#1441).

**Security (dependencies)**

- Cleared every remaining npm audit and pip-audit finding. react-router and react-router-dom move to 7.18.2, which retires the documented CSRF exception in the CI audit gate — upstream backported the fix, so the exemption lapsed on its own and the allowlist is now empty. dompurify moves to 3.4.13 (shipped, but on a path this app never reaches: no hooks registered, in-place mode unused). js-yaml and nanoid are overridden, both development-only via eslint and postcss.
- Patched two build-time frontend dependencies flagged by npm audit (GHSA-r28c-9q8g-f849, GHSA-mh99-v99m-4gvg, GHSA-rgw5-rvv9-x895).

---
**Sponsors**

Bambuddy is sustainable thanks to people who put their money where their use is. If this release saved you time or kept your farm running, the project runs on recurring contributions — there's no paid tier, no telemetry, no upsell, just sustainable maintenance.

- GitHub Sponsors (recurring, 5 tiers from $5/mo to $300/mo) — https://github.com/sponsors/maziggy
- Ko-fi (one-time or recurring) — https://ko-fi.com/maziggy
2026-08-15 16:13:31 +02:00
maziggy 907de4d64d Suppress two Bandit false positives in the new FTP and batch-order tests
The 1.2.5.3 code-scanning run flagged two new alerts, both in test files
added this release, and both false positives.

B402, the ftplib import in the #2780 connect-cleanup tests, is the HIGH
finding that failed the check. The test imports ftplib to construct the
exceptions BambuFTPClient.connect has to survive -- error_perm and
error_temp, at lines 50, 51 and 75. Nothing in the file opens a
connection, and bambu_ftp.py already carries the same marker on its own
import.

B108, the /tmp path in the batch-order archive fixture, is the MEDIUM
one. The value is a string written into PrintArchive.file_path so the
row has a path; nothing ever opens it. Every other archive fixture in
the suite carries the same marker on the same idiom.

Both markers follow the wording already in test_bambu_ftp.py and
test_sjf_scheduling.py. Bandit's medium+ count over backend/ drops from
17 to 15, and neither file contributes to what is left.
2026-08-15 16:09:57 +02:00
maziggy 0e591b2199 Suppress two Bandit false positives in the new FTP and batch-order tests
The 1.2.5.3 code-scanning run flagged two new alerts, both in test files
added this release, and both false positives.

B402, the ftplib import in the #2780 connect-cleanup tests, is the HIGH
finding that failed the check. The test imports ftplib to construct the
exceptions BambuFTPClient.connect has to survive -- error_perm and
error_temp, at lines 50, 51 and 75. Nothing in the file opens a
connection, and bambu_ftp.py already carries the same marker on its own
import.

B108, the /tmp path in the batch-order archive fixture, is the MEDIUM
one. The value is a string written into PrintArchive.file_path so the
row has a path; nothing ever opens it. Every other archive fixture in
the suite carries the same marker on the same idiom.

Both markers follow the wording already in test_bambu_ftp.py and
test_sjf_scheduling.py. Bandit's medium+ count over backend/ drops from
17 to 15, and neither file contributes to what is left.
2026-08-15 16:09:20 +02:00
maziggy 6f6d16eb8f Updated CHANGELOG 2026-08-15 16:04:41 +02:00
maziggy e649d9677e Updated CHANGELOG 2026-08-15 16:04:05 +02:00
MartinNYHC 41acb9ce51 Merge branch 'main' into 1.2.5.3 2026-08-15 15:39:03 +02:00
maziggy fa5504ee46 Updated CHANGELOG 2026-08-15 15:32:27 +02:00
maziggy 95e28e39ce Bumped version 2026-08-15 15:07:17 +02:00
maziggy 1f3d66e29d [Feature]: Server-Side Slicing on linux/arm64 systems (#1900)
* Add override file for ARM64 setups.

    This commit adds an override file which explicitly specifies the container platform to be linux/amd64.
    It forces docker to pull/build/run the amd64 image (even on arm64 hosts).
    Assuming binfmt support is set up, this will run the amd64 applications via emulation.

    * Add arm64 override file information to README.

    Adds a section covering the experimental setup for
    arm64 hosts to the README.

    * Make the ARM64 override survive the next compose command (#1900)

    The override only applies while both -f flags are on the command line, and
    every other instruction in this README is written bare. An ARM64 user who
    followed the update steps would drop the platform pin without noticing: a
    manifest error today, and a silent switch off emulation once native ARM64
    images ship. The quick start now writes COMPOSE_FILE into .env, so the rest
    of the file works unchanged on ARM64 -- verified both ways, with and without
    that line.

    Two things the setup needs stated where it is read rather than one hop away
    in the wiki: binfmt has to be registered on the host or the container dies
    with "exec format error", and emulation costs roughly 3-6x native slice
    time. Both now lead the section, and the separate-x86_64-box route stays the
    recommendation it was -- emulation is the fallback for people who have no
    second machine, not a replacement.

    The compose file's own header said ARM64 was a dead end. It now points at
    the override, for anyone who reads the stack instead of the README.
2026-08-15 15:03:46 +02:00
maziggy e899022116 fix(profiles): read the companion files that hold a preset's real gcode
A bundled preset can keep a setting in `<preset> template <key>.json`, a
    file the preset itself does not reference -- the desktop slicer finds it
    by name. Walking only `inherits` never reached it, so every one of the 56
    instantiable BBL machine presets resolved `machine_start_gcode` to the
    577-character generic block on fdm_machine_common instead of its own
    6.5-21 KB one. That block holds the M620 AMS load and the M1002
    gcode_claim_action calls, so a print sliced from it heats the bed, moves
    the toolhead and extrudes nothing (bambuddy#2838).

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

    Covered against the shipped bundle, not fixtures: a new e2e spec resolves
    all 56 presets inside the image and fails on any that still lands on the
    generic block.
2026-08-15 15:03:28 +02:00
maziggy 2025731f91 Updated BACKERS.md 2026-08-15 15:03:09 +02:00
maziggy 2d324dc7d0 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 15:02:53 +02:00
maziggy ad2b22dd6b 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-15 15:02:36 +02:00
maziggy aa4df0c57a Build the slice output's path from a name a folder can have (#2832)
A print's display name comes from inside the 3MF, not from the filename,
    so a MakerWorld title arrives with its punctuation: "Planter Pot with
    Drip Tray, 12 cm / 5 inches". The slice-to-archive sink used it verbatim
    for the output folder and the output file, and a slash in a folder name
    is not a character -- it is another folder. mkdir(parents=True) created
    the level it implied and the file's own join added a third that nobody
    had made, so the slice failed with ENOENT on a path that half existed.
    Renaming the print first was the only way through.

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

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

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

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

    Display names are also stripped of control characters on the way into
    the database, in the schema and in the archive service, cleaned before
    the filename fallback rather than after it so a whitespace-only embedded
    name still falls through to the filename. The validator hands back
    anything that is not a string rather than iterating it, so the field
    still answers a list or a bare int with a 422 instead of accepting the
    one and failing on the other.
2026-08-15 15:02:17 +02:00
maziggy dde724f83c Repair no-3MF archives' photos and their silent filament writes (#1820)
Two faults behind the same kind of print: one that arrives without a
    retrievable 3MF, which on an H2S is any job started from the printer's
    own internal library.

    Such an archive has no file_path, and Path("").parent is Path("."), so
    every site that derived the archive's folder from it landed on the data
    directory itself. The finish-photo capture spotted that and wrote to
    <archive_dir>/<id>/photos instead. Nothing else did. The photo was
    written in one place and looked for in another: reads 404'd, deletes
    dropped the name and left the file, and the notification attachment
    never found the image. Hand-uploaded photos worked only because upload
    and read agreed with each other rather than with the capture. Give the
    question one owner in utils/archive_paths and have all four sites ask
    it. Lookups check the old shared location too, so photos already
    uploaded there stay reachable; uploads now go where captures go.

    Separately, the remain%-delta fallback that stands in for a missing 3MF
    can charge nothing for several reasons, and did so without a word. The
    AMS reading is coarse and, on the reporter's printer, noisy: it rises
    mid-print, swings five points over a job, sits at 100% through a
    36-minute print on a fresh spool, and goes negative on a nearly empty
    one -- which the start-of-print gate rejects, dropping the only slot
    that was printing. Two of their prints went uncounted for two different
    reasons and both read as "no spools updated", which is also what a print
    with nothing to charge prints. Name the slot and the two readings in
    each case, on the Spoolman path and on the internal-inventory path,
    which has carried the same gates since #1119.

    The Spoolman path also had no notion of which slots the print used, so a
    spool swapped into an idle slot mid-print reads as consumption and is
    billed to whoever that slot is assigned to -- the fault #1269 fixed for
    the internal tracker, still open here, and likeliest on exactly the
    prints this fallback serves, where nothing else narrows the field. Use
    the same three pieces of evidence it does: the print's mapping, its
    mid-print tray changes, and the tray it started on. The last needs
    storing, because the internal tracker's row is deleted before this runs
    and a screen-started print has no mapping to fall back on -- hence a new
    nullable column, and no backfill, since a row from before it existed has
    nothing to say. Where no evidence exists at all, every slot is still
    considered.

    Both paths also treated tray_now == 255 as naming a slot. It does not:
    it is the field's initial value, the fallback for an unparseable
    reading, and what it reports with nothing loaded. Mapped as a tray id it
    becomes (255, 1), so as the only evidence it excluded every real slot
    and charged nothing at all -- this issue's own bug, arriving by a new
    route. On the internal path that is live today; on the Spoolman path it
    would have shipped with the guard above. The external holder reports 254
    when it is genuinely in use.

    The arithmetic is untouched: at one percent per step this cannot resolve
    a small print, and pretending otherwise would be worse than saying so.
2026-08-15 15:01:42 +02:00
maziggy 15eace71d6 Show the plug that powers the printer in the card's Power row (#2830)
A printer card has one Power row: a plug name, its draw, and the auto-off
    and on/off buttons. Which plug filled it was decided by nothing -- the
    endpoint returned the first row the database handed back that was not a
    Home Assistant script, from a query with no ORDER BY.

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

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

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

    The scripts endpoint shares the same pick and excludes it, so a
    switchable main plug is not repeated as a button directly below itself.
    A script is left in place: a printer whose only entities are scripts
    falls back to showing one in the power row, and taking it out of the
    button row too would cost it the one-click run it has always had.
2026-08-15 15:01:19 +02:00
maziggy 46aa6affd3 Match a print completion to its queue job the way the printer names it (#2829)
Bambuddy has no run identifier to tie a completion to a queue row, so it
    finds the row by printer and status='printing' alone. b5a34b7ba added a
    check that the completion's subtask name agrees with the file the row was
    dispatched with, so the printer's own calibration runs cannot close
    someone's job early. It compared the two names verbatim.

    The printer does not echo them verbatim. It substitutes underscores for
    spaces, so 'H2D_Carbon_Filter_(V2)_Body & Solid Lid' came back as
    'H2D_Carbon_Filter_(V2)_Body_&_Solid_Lid', the check refused it, and the
    row stayed printing. check_queue counts every printing row as a busy
    printer and nothing else ever closes one, so the printer's queue stopped
    until someone cancelled by hand. It also truncates long names and marks
    the cut with '...', which would have done the same to any long title.

    Compare on the canonical form instead -- case, spaces and underscores --
    which is the rule the 3MF lookup in this module has always used for the
    same names, and treat a truncation marker as a prefix match. The check
    keeps its purpose: the same printer the same day correctly refused a
    completion for auto_pa_line_calib_mode.

    One comparison being stricter than reality should not be able to stop a
    queue indefinitely, so the scheduler now closes a row itself when it has
    been printing for five minutes after its printer went terminal, with the
    status that state implies. A real completion arrives within seconds, so
    this only sees rows that were already stranded, and a disconnected
    printer never qualifies. It restores the queue only -- notifications,
    billing and auto-off are not replayed minutes late.
2026-08-15 15:01:00 +02:00
maziggy 2548a5d49d 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-15 15:00:42 +02:00
maziggy b83eea07cd 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-15 15:00:16 +02:00
maziggy 8e553289db 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-15 14:59:41 +02:00
maziggy 7787b3fc0f 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-15 14:59:19 +02:00
maziggy 58594c60c9 Print an H2C two-nozzle plate from the carriage it was levelled on
The two carriages were the wrong way round: extruder index 0 was treated as
    the fixed hotend and index 1 as the swappable rack, and it is the other way
    about. A plate using both was levelled with one nozzle and printed with the
    other, several millimetres off the plate.

    Three sources agree, and disagreed with the code. Telemetry reports
    ams_extruder_map {'0': 1, '1': 0, '2': 0}. BambuStudio, dispatching a plate
    that used all three of those AMS units, sent the filament from the unit on
    extruder 1 to physical nozzle 1 and the ones on extruder 0 to rack positions
    16 and 18, and that print completed. And the two constants could not both
    have been right: _FIXED_NOZZLE_ID is 1 while the fixed extruder was 0, in a
    scheme where physical nozzle id N sits on extruder N.

    The old value came from the #2800 hardware A/B, where [17, -1, -1, 1] printed
    in mid-air and [1, -1, -1, 17] printed correctly. That result stands -- it
    established which wire worked. The extruder indices were not measured by it;
    they were inferred by pairing the working wire with a slot_extruders list
    produced by the 3MF reader that has since turned out to mis-read exactly
    these files. The reasoning is recorded at the constants so a future
    regression report is not re-litigated from scratch.

    Also withholds nozzle_mapping entirely when more than one filament group
    needs the rack. Two groups on one extruder means that extruder is a rack and
    the plate wants a different hotend per group -- which physical slot each
    takes is the slicer's choice against the live rack and is stated nowhere in
    the file, since both groups can carry identical diameter and nozzle type.
    Studio dispatched such a plate to 16 and 18; nothing here can reproduce that,
    and answering anyway is what printed in mid-air, so the firmware picks.

    Restores the fixed 32-entry padding that dfeac792f replaced with the plate's
    slot count. That was derived from a single 3-entry capture which turned out
    to be a calibration job; Studio's dispatch of a real project print on the
    same machine is 32 entries.

    Both constants are read in one function, on the nozzle-rack path, so no other
    model is affected. Verified on hardware: the plate that printed in mid-air
    now prints.
2026-08-15 14:59:02 +02:00
maziggy 3ce4ecfcf7 Report a print stage we cannot name at the default log level
STAGE_NAMES is hand-maintained and every new model adds to it, so a printer
    occasionally reports a number that is not in it and the card reads "Unknown
    stage (72)" -- which an H2C did, where the table runs to 66 and then jumps
    to 74. Stage transitions were logged only at DEBUG, off in normal running,
    so the sole record that it had happened was the card itself, and by the
    time anyone looked the printer had moved on.

    The asymmetry is the point: a stage we can name is worth DEBUG, and the one
    we cannot is the interesting one. An unnamed stage is now logged at INFO,
    once per stage number per session, with the model, the stage it came from
    and the print state at the time -- which is what naming it afterwards
    needs. Named stages are unchanged, so a normal print logs nothing new. -1
    is excluded: it is Bambuddy's own "not in a stage" sentinel and the field's
    initial value, so every print would otherwise report it on the way out of
    its last real stage.

    Fixes a latent crash found while testing this. The stage-change log line
    builds its text before the log level is consulted, so get_stage_name runs
    on every transition whatever the level is set to; a stg_cur that was not
    hashable -- malformed telemetry rather than an unknown stage -- raised
    TypeError out of STAGE_NAMES.get and aborted the whole state update.
    Labelling a value can no longer do that.
2026-08-15 14:58:45 +02:00
maziggy c5cd0dbe77 Stop an H2C refusing a multi-colour print as a hotend mismatch
The print uploaded, the printer took the command, and stopped at once with
    HMS 0500-4047 -- "the available hotend quantity or model does not match the
    sliced file". nozzle_mapping told the printer one of the plate's filaments
    went to no hotend while ams_mapping named the tray it comes from, and the
    firmware will not start a job on that contradiction.

    Each filament in a 3MF names the group it belongs to, and on every other
    dual-nozzle Bambu the group number is also the extruder index, so it was
    read as one. On a rack machine it is not: the rack carriage holds six
    hotends to the fixed carriage's one, so the slicer writes a group per
    nozzle rather than per carriage. The failing plate carried groups 0, 1 and
    2 against a two-entry physical_extruder_map, and the filament in group 2
    was dropped -- indistinguishable downstream from a slot the plate does not
    print, which is what reached the wire as -1.

    extract_nozzle_mapping_from_3mf now resolves the group through the table
    the file states for itself, the <nozzle id extruder_id> elements in
    slice_info.config. Files carrying no such table keep the direct index, so
    H2D slices are unaffected. A filament that still cannot be placed drops
    the whole mapping with a logged reason instead of half an answer: the
    firmware then picks its own nozzle, which is the pre-existing behaviour
    and far better than an answer that contradicts itself.

    Two related faults fixed in the same pass. The mapping was read across
    every plate in the file, so on a multi-plate project a slot took its
    extruder from whichever plate came last; it is now scoped to the plate
    being dispatched, in extract_filament_requirements as well. And the array
    is now one entry per filament slot, matching BambuStudio's own dispatch of
    [1, 16, 16] for a three-filament plate, rather than padded to a fixed 32.

    Verified against the file that failed: slot extruders [-1, 1, 0] became
    [0, 1, 0], and the wire [-1, 16, 1, -1 x29] became [1, 16, 1].
2026-08-15 14:58:27 +02:00
maziggy 1b944739d2 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-15 14:58:09 +02:00
maziggy 12a34448f9 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-15 14:57:41 +02:00
maziggy 292b1b416b Pick the nearest eligible filament colour instead of the first one in tray order (#2804) (#2823) 2026-08-15 14:57:14 +02:00
maziggy 3031e52ddf fix(slicer): stop a 3MF from switching off supports its process preset turned on (#2820)
--load-settings is authoritative, so since #1881 four support fields
    travel the other way -- enable_support, the two filament slots, and
    support_type -- lifted out of the source 3MF and written over the picked
    process preset. Bambu's shipped presets all set enable_support: 0
    because supports are a per-print decision, and without the carry a
    project exported with PVA in the interface slot sliced single-material.

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

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

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

    Also log the carry with the fields it took. The slice dialog shows the
    picked preset's values, so a carried field silently disagrees with what
    was on screen and this step logged nothing at all -- the report chased an
    unrelated sanitiser line about the source file's own settings, which was
    the only thing in the log that mentioned any of these keys.
2026-08-15 14:56:58 +02:00
maziggy 558159c95c fix(queue): stop a library-file delete from destroying the jobs queued against it (#2819)
Nothing tied a library file to the queue rows pointing at it, and the FK
    that describes the relationship is ON DELETE CASCADE -- which SQLite does
    not enforce and PostgreSQL does. So the same fault had two faces: rows
    left pointing at a file that no longer existed, failing at the printer
    with "Library file not found" days later, or rows deleted outright with
    no error and no history.

    Two routes into it, both fixed by taking the queue off the file before
    the row goes.

    Dispatch (the reported case): quantity>1 on the printer-card
    upload-and-print flow puts cleanup_library_after_dispatch on every copy,
    and _clone_queue_item copies library_file_id onto batch clones, so the
    first dispatch consumed the file the rest were waiting on. The copies are
    now pointed at the archive that dispatch just created -- it holds its own
    copy of the 3MF -- and the consume flag is cleared on them. A copy already
    printing from its own archive keeps it, a finished one keeps its outcome,
    and a cross-model item (#671) keeps any candidate this does not consume.

    Deletion: the File Manager, bulk delete, folder delete, emptying the trash
    and the retention sweeper all removed rows with queued work against them.
    Folder delete did not even clear the cross-model candidates, because the
    file-id walk it already performs threw its result away. Jobs waiting on a
    deleted file are now cancelled at that moment, naming the file, and every
    other row referring to it is detached rather than destroyed -- print
    history and batch progress are counted from those rows. A job that is
    printing is left alone: what is deleted is the library copy, not the copy
    on the machine. The trash is reversible so it still changes nothing about
    the queue, and a job dispatched while its file is in the trash now says so
    instead of "not found".

    Verified row for row on PostgreSQL 16 as well as SQLite: without this,
    PostgreSQL deletes every queue row referencing the file.
2026-08-15 14:56:41 +02:00
maziggy 03ec2aef4d 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-15 14:56:24 +02:00
maziggy 3ac289fccb Attribute filament correctly when AMS backup swaps spools mid-print
Everything the completion path needs to split a print's filament across
    the trays it fed from lived only in memory: the dispatched plate and
    slot-to-tray mapping, the spool-assignment snapshot, and the tray-change
    log. A print that outlived a restart lost all of it and fell back to
    what the printer reports at completion -- which, with AMS Filament
    Backup on, is the substitute tray. The whole print was charged to the
    spool that only finished it while the spool that ran dry was charged
    nothing.

    Persist that context in a new active_print_sessions row, append tray
    changes as they happen, and restore both the session and the printer's
    tray-change log at restart recovery. Seed the log from the current tray
    when there is nothing to restore, since last_loaded_tray advances even
    when no change is logged.

    Rank the queue item's stored ams_mapping above the printer's live
    mapping field, which is what backup rewrites. Recover plate_id from the
    archive or queue item, and give extract_layer_filament_usage_from_3mf a
    plate_id instead of taking the first .gcode member -- a Bambu Studio
    export stores plate 2 first, so per-layer figures were measured against
    the wrong plate for both inventory backends.

    Stop auto-unlinking a spool assignment when its slot reports empty
    during a running print. At a runout the spool is still in the AMS, and
    dropping the link leaves the completion path nothing to charge.

    Capture the print-start context for both inventory backends. Spoolman's
    own durable row (#1820) carries its plate-scoped figures and dispatched
    mapping but not the tray-change log, and its slot assignments -- the
    way. Registration in _active_sessions stays gated, since on_ams_change
    reads it to decide whether to skip the remain%-based weight sync (#880).
2026-08-15 14:56:04 +02:00
maziggy 0cdc9944a4 Do not close a queue item on a completion for another print
on_print_complete finds the row to close by printer and status='printing'
    alone. The MQTT payload carries a subtask name but no run identifier, so
    nothing tied the event to the row: any completion delivered for a printer
    closed whichever job was printing on it. A job closed that way is marked
    completed while the printer is still working, leaves the queue for
    history, and strands the rest of its batch, because the queue correctly
    refuses to dispatch onto a busy printer.

    The handler now checks the completion against the file the row was
    dispatched with, recovered from its archive, and leaves the row alone
    when they disagree. Only a positive disagreement refuses: no archive, no
    file name or no subtask name is unverifiable rather than wrong, and
    refusing those would strand the item in 'printing' and wedge the queue --
    the failure the loose lookup was avoiding in the first place.

    This surfaced through the test suite, which could reach a real database.
    conftest built its own SQLite engine, but core/config.py snapshots
    DATABASE_URL at import time and core/database.py builds the module-level
    engine and async_session from it. Tests reaching code that opens its own
    session -- run_with_retry, which the completion path uses, takes its
    sessions from core.database and so is untouched by the widespread
    patch("backend.app.main.async_session") -- therefore talked to whatever
    database .env named: the developer's own SQLite file on a plain checkout,
    a live install with a PostgreSQL .env. DATABASE_URL is now redirected to
    a throwaway file before any app import, and the run aborts rather than
    starts if that did not take.
2026-08-15 14:55:45 +02:00