Commit Graph
2001 Commits
Author SHA1 Message Date
maziggy a1e5afbd2d Keep a dispatch's retries out of the FTPS cool-off (issue #2898)
A failed TLS handshake arms a 300s per-IP cool-off, and connect()
    consulted it for every caller. A print dispatch retries after 2s, so
    once the cool-off was armed all four attempts were answered from the
    gate rather than the network, and every further job queued for that
    printer failed the same way for the rest of the window. The reporter's
    farm lost three jobs to one handshake error, with the retry budget
    contributing nothing to any of them.

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

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

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

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

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

    Tests count sockets rather than return values, since "returned False"
    looks identical whether or not anything was attempted -- which is what
    made the original report a log dive. Reverting any one of the five
    behaviours above fails a distinct test.
2026-08-23 12:43:00 +02:00
maziggy 38abf57bb7 Let a print start on a nozzle the printer can fetch (issue #2885)
The nozzle-diameter guard compared the sliced diameter against the
    mounted hotends alone. On an H2C both hotends commonly read the same
    size, so a job sliced for anything else was failed before upload with
    "install the matching nozzle before printing" -- even with that nozzle
    sitting in the tool-changer rack, which the printer fetches by itself
    as part of starting a print. The reporter saw it as an asymmetry:
    going to 0.4mm always worked, going to 0.2mm never did, and only
    fetching the nozzle by hand on the printer's own screen let the print
    run. It was never only about 0.2mm -- with a 0.6mm docked and 0.4mm
    hotends, a 0.6mm slice was refused identically.

    Placement is what made it fatal rather than merely wrong. The guard
    runs near the top of _start_print and the rack picker near the bottom,
    so the item was failed before the code that would have chosen the dock
    ever ran.

    Bambuddy had the rack contents the whole time. nozzle_info carries an
    entry per nozzle -- ids 0/1 for the hotends, 16-21 for the docks --
    each with its diameter, so the guard now tests the slice against both
    sets. It keys off the ids rather than the printer model: only a rack
    machine reports 16-21, which leaves no registry to keep in sync. An
    empty dock is absent from the payload entirely, so an id appearing
    there already means a nozzle is in it. stat is left uninterpreted; it
    read 0 on every entry, occupied and empty alike.

    A diameter in neither a hotend nor a dock still stops the print before
    it uploads, and the message now names both sets so the machine's real
    stock is visible.

    The same telemetry showed a second fault, pointing the other way. A
    hotend with nothing mounted is still reported, keeping the diameter of
    the nozzle it last held -- measured at idle, where the rack-side hotend
    read 0.4mm with max_temp 0 and serial "N/A" after parking its nozzle
    back in the dock. That stale value counted as installed. Presence now
    comes from the serial and the temperature rating, and emptiness has to
    be stated rather than merely unstated: the serial must be the
    firmware's explicit "N/A" and the rating absent. A firmware that
    reports neither field normalises to exactly that, and reading it as
    empty would switch the guard off on that machine.

    Confirmed on an H2C with 0.4mm hotends and a 0.2mm in R6: the job
    dispatches with nozzle_mapping [21], chosen by Bambuddy rather than
    left to the firmware.
2026-08-23 12:41:45 +02:00
maziggy 7c8f1f9435 Keep a dispatch's retries out of the FTPS cool-off (issue #2898)
A failed TLS handshake arms a 300s per-IP cool-off, and connect()
consulted it for every caller. A print dispatch retries after 2s, so
once the cool-off was armed all four attempts were answered from the
gate rather than the network, and every further job queued for that
printer failed the same way for the rest of the window. The reporter's
farm lost three jobs to one handshake error, with the retry budget
contributing nothing to any of them.

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

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

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

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

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

Tests count sockets rather than return values, since "returned False"
looks identical whether or not anything was attempted -- which is what
made the original report a log dive. Reverting any one of the five
behaviours above fails a distinct test.
2026-08-22 10:05:05 +02:00
maziggy 4961990a7a Let a print start on a nozzle the printer can fetch (issue #2885)
The nozzle-diameter guard compared the sliced diameter against the
mounted hotends alone. On an H2C both hotends commonly read the same
size, so a job sliced for anything else was failed before upload with
"install the matching nozzle before printing" -- even with that nozzle
sitting in the tool-changer rack, which the printer fetches by itself
as part of starting a print. The reporter saw it as an asymmetry:
going to 0.4mm always worked, going to 0.2mm never did, and only
fetching the nozzle by hand on the printer's own screen let the print
run. It was never only about 0.2mm -- with a 0.6mm docked and 0.4mm
hotends, a 0.6mm slice was refused identically.

Placement is what made it fatal rather than merely wrong. The guard
runs near the top of _start_print and the rack picker near the bottom,
so the item was failed before the code that would have chosen the dock
ever ran.

Bambuddy had the rack contents the whole time. nozzle_info carries an
entry per nozzle -- ids 0/1 for the hotends, 16-21 for the docks --
each with its diameter, so the guard now tests the slice against both
sets. It keys off the ids rather than the printer model: only a rack
machine reports 16-21, which leaves no registry to keep in sync. An
empty dock is absent from the payload entirely, so an id appearing
there already means a nozzle is in it. stat is left uninterpreted; it
read 0 on every entry, occupied and empty alike.

A diameter in neither a hotend nor a dock still stops the print before
it uploads, and the message now names both sets so the machine's real
stock is visible.

The same telemetry showed a second fault, pointing the other way. A
hotend with nothing mounted is still reported, keeping the diameter of
the nozzle it last held -- measured at idle, where the rack-side hotend
read 0.4mm with max_temp 0 and serial "N/A" after parking its nozzle
back in the dock. That stale value counted as installed. Presence now
comes from the serial and the temperature rating, and emptiness has to
be stated rather than merely unstated: the serial must be the
firmware's explicit "N/A" and the rating absent. A firmware that
reports neither field normalises to exactly that, and reading it as
empty would switch the guard off on that machine.

Confirmed on an H2C with 0.4mm hotends and a 0.2mm in R6: the job
dispatches with nozzle_mapping [21], chosen by Bambuddy rather than
left to the firmware.
2026-08-22 08:46:07 +02:00
maziggy 5925e387f0 Name an AMS slot's colour by its material, not its hex alone (issue #2875)
A hex is not one colour in Bambu's range. #FFFFFF is Jade White in PLA
    Basic, Ivory White in PLA Matte and plain White in six other materials;
    popover resolved its title from the hex alone, against a map that keeps
    one name per hex, so an ivory Matte spool read "Jade White" while the
    profile line beside it correctly read Matte Ivory.

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

    A slot with a spool assigned from Inventory is titled with that spool's
    own colour name: it is the roll the user said is in there. Bambu
    internal codes are still rejected as non-names (#857).
2026-08-19 09:33:08 +02:00
maziggy a13e9f0d8e Stop waking printers that cannot print the queued job (issue #2876)
The queue's smart-plug step chose a printer to switch on by model alone.
    With a class-targeted job and every matching printer off, it walked the
    farm in printer-ID order, woke the first machine with an Auto On plug,
    and only then read the loaded filament -- so a job for a colour loaded at
    the far end of the farm woke every earlier printer in turn and left each
    one running until its own auto-power-off timer expired.

    The colours were known the whole time. A printer keeps its last reported
    AMS and external-spool trays after the power goes; mark_power_off blanks
    connected and state and leaves raw_data alone. The wake step now asks the
    same three questions the matcher asks a live printer -- required types,
    forced colours, preferred colours -- of that reading, and passes over a
    printer it rules out. A printer with no reading at all is still woken:
    never having heard is not the same as nothing being loaded.

    The matcher reports such a printer as needing filament rather than as
    offline, so the waiting reason explains why nothing was switched on.

    The manager now keeps a printer's last tray reading when it drops the
    client, and the queue falls back to that. _power_on_and_wait calls
    connect_printer in a retry loop, so an attempt that timed out used to
    erase the reading the next one depends on. The record is kept beside the
    clients, not inside one: the AMS merge is additive, so feeding it back
    into live status would merge an unplugged AMS unit back in for good.
2026-08-19 09:32:49 +02:00
maziggy aa6723c608 Take the layer height from the plate that actually printed
The archive card, the library file details and the slice dialog all read
    the layer height from a 3MF's project_settings.config. That records the
    project's settings and can still describe an earlier process or another
    plate; the plate's own G-code - what the printer executes - was never
    consulted for it, because the parser read the first 4KB, enough for the
    layer count in the header block but not for the config block that carries
    layer_height 14-25KB in. A print running at 0.08 on the H2C archived as
    0.2 with the layer count from the same file correct beside it.

    The plate G-code now wins wherever the two disagree, and the plate that
    was printed is the one read - the header parse used to take the first
    gcode entry in the zip regardless of which plate the archive was for.
    Source 3MFs, which carry no G-code, keep the project value as before.

    ---

    Stop carrying a file's layer height over the preset you picked

    Bambuddy carries a designer's process deviations across a re-slice
    (#2622) and pre-ticked every one that was not machine-coupled.
    layer_height is one MakerWorld projects routinely carry, so picking
    "0.08mm High Quality" for a file whose designer had moved layer height to
    0.2 sliced at 0.2 while the dropdown still read 0.08 - the same 0.2 the
    settings panel showed, tagged "from file".

    Layer height and first layer height are now classified preset_defining
    and treated like the machine-coupled keys: offered, never pre-selected.
    The flag travels on DesignOverride so the modal and the backend agree,
    and the panel's badge names the conflict and shows the preset's own value
    next to the file's, so ticking one is a deliberate choice.
2026-08-19 09:32:31 +02:00
maziggy dbbcf12619 Keep the printer's name on statistics after it is deleted (issue #2873)
Every per-printer breakdown resolved the name against the printers that
    exist now, so deleting a printer and choosing to keep its prints turned
    "Ultron" into "Printer 1" in Prints by Printer, the success-rate and
    time-accuracy lists, and Failures by Printer. Archives lose their printer
    on that delete as well, so nothing was left to read a name from.

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

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

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

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

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

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

    ---

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

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

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

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

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

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

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

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

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

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

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

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

    This does not dispatch to an unreachable printer: _is_printer_idle still
    requires a connection. Releasing the gate is what lets the queue switch
    the printer on for the next job instead of passing it over.
2026-08-19 09:30:30 +02:00
maziggy 3901043238 Name an AMS slot's colour by its material, not its hex alone (issue #2875)
A hex is not one colour in Bambu's range. #FFFFFF is Jade White in PLA
Basic, Ivory White in PLA Matte and plain White in six other materials;
popover resolved its title from the hex alone, against a map that keeps
one name per hex, so an ivory Matte spool read "Jade White" while the
profile line beside it correctly read Matte Ivory.

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

A slot with a spool assigned from Inventory is titled with that spool's
own colour name: it is the roll the user said is in there. Bambu
internal codes are still rejected as non-names (#857).
2026-08-19 09:06:11 +02:00
maziggy dd50c51c1b Stop waking printers that cannot print the queued job (issue #2876)
The queue's smart-plug step chose a printer to switch on by model alone.
With a class-targeted job and every matching printer off, it walked the
farm in printer-ID order, woke the first machine with an Auto On plug,
and only then read the loaded filament -- so a job for a colour loaded at
the far end of the farm woke every earlier printer in turn and left each
one running until its own auto-power-off timer expired.

The colours were known the whole time. A printer keeps its last reported
AMS and external-spool trays after the power goes; mark_power_off blanks
connected and state and leaves raw_data alone. The wake step now asks the
same three questions the matcher asks a live printer -- required types,
forced colours, preferred colours -- of that reading, and passes over a
printer it rules out. A printer with no reading at all is still woken:
never having heard is not the same as nothing being loaded.

The matcher reports such a printer as needing filament rather than as
offline, so the waiting reason explains why nothing was switched on.

The manager now keeps a printer's last tray reading when it drops the
client, and the queue falls back to that. _power_on_and_wait calls
connect_printer in a retry loop, so an attempt that timed out used to
erase the reading the next one depends on. The record is kept beside the
clients, not inside one: the AMS merge is additive, so feeding it back
into live status would merge an unplugged AMS unit back in for good.
2026-08-19 08:26:43 +02:00
maziggy 7e77bf5833 Take the layer height from the plate that actually printed
The archive card, the library file details and the slice dialog all read
the layer height from a 3MF's project_settings.config. That records the
project's settings and can still describe an earlier process or another
plate; the plate's own G-code - what the printer executes - was never
consulted for it, because the parser read the first 4KB, enough for the
layer count in the header block but not for the config block that carries
layer_height 14-25KB in. A print running at 0.08 on the H2C archived as
0.2 with the layer count from the same file correct beside it.

The plate G-code now wins wherever the two disagree, and the plate that
was printed is the one read - the header parse used to take the first
gcode entry in the zip regardless of which plate the archive was for.
Source 3MFs, which carry no G-code, keep the project value as before.

---

Stop carrying a file's layer height over the preset you picked

Bambuddy carries a designer's process deviations across a re-slice
(#2622) and pre-ticked every one that was not machine-coupled.
layer_height is one MakerWorld projects routinely carry, so picking
"0.08mm High Quality" for a file whose designer had moved layer height to
0.2 sliced at 0.2 while the dropdown still read 0.08 - the same 0.2 the
settings panel showed, tagged "from file".

Layer height and first layer height are now classified preset_defining
and treated like the machine-coupled keys: offered, never pre-selected.
The flag travels on DesignOverride so the modal and the backend agree,
and the panel's badge names the conflict and shows the preset's own value
next to the file's, so ticking one is a deliberate choice.
2026-08-18 16:23:32 +02:00
maziggy 28781ea558 Keep the printer's name on statistics after it is deleted (issue #2873)
Every per-printer breakdown resolved the name against the printers that
exist now, so deleting a printer and choosing to keep its prints turned
"Ultron" into "Printer 1" in Prints by Printer, the success-rate and
time-accuracy lists, and Failures by Printer. Archives lose their printer
on that delete as well, so nothing was left to read a name from.

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

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

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

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

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

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

---

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    The probe filename arrives over MQTT and becomes both a remote path and a
    local temp filename, so names carrying separators, traversal or control
    characters are declined rather than cleaned.
2026-08-17 15:51:22 +02:00
maziggy 5665babab4 Use the printer's own plug for energy when several are linked (issue #2859)
Per-print energy is one plug's meter read at the start of a print and
    again at the end. Both readings asked for "the plug on this printer"
    with scalar_one_or_none(), which raises on two rows. Linking a second
    plug to a printer - a dry box, a filter fan, a lights script - therefore
    stopped energy tracking on that printer outright, and did it silently:
    the print-start handler logged the exception as an ordinary failure and
    the print-end handler then reported "no start kWh recorded", which is
    also what it says for a printer with nothing linked to it.

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

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

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

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

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

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

    The user-facing copy shipped this morning is corrected in the same
    change, because it was written before we understood how Bambu Studio
    actually chooses. Its Print button always uses internal memory; only Send
    offers Cache or External, and that defaults to Cache too. So the advice
    now leads with the two routes that take one step -- start the print from
    Bambuddy, or slice in OrcaSlicer -- and offers Send-with-External and a
    separate print start as the way to stay in Bambu Studio. The earlier
    wording named no remedy at all and blamed the printer's firmware for a
    choice the slicer makes. Banner and diagnostic, thirteen locales, README,
    bundle rebuilt.
2026-08-17 15:49:09 +02:00
maziggy e5b8e76ea1 Recognise our own print dispatch instead of guessing at a magic number
(#2843 follow-up)

    Bambuddy records the project_file behind every print, because that command
    names where the sliced file was put and so decides whether the archive can
    have a thumbnail and slicer metadata at all. Its own dispatches were told
    apart from a slicer's by testing sequence_id against "20000", on the
    belief that 20000 was Bambuddy's alone.

    It never was. 20000 is the slicer convention Bambuddy copied --
    virtual_printer/bind_server documents the slicer sending exactly that
    during detect -- and both slicers count up from it. Measured on the wire:
    OrcaSlicer dispatched 20000 and then 20001, BambuStudio 20009 and 20010.
    So whichever dispatch happened to land on the shared value was filed as
    ours and never recorded, and after a slicer restart that is the first
    print you send. The test was wrong in the other direction too: it called
    every value above 20000 external, including ones we had sent ourselves,
    which only stayed invisible because we always send exactly 20000.

    Ownership is now established by remembering the job actually dispatched
    -- sequence id, file, url and subtask name -- and consuming that marker
    on the echo. One-shot deliberately: a slicer reprint of the same file a
    moment later is somebody else's print and must not hide behind our last
    one.

    Nothing about printing or archiving changes. current_project_url and
    ams_mapping are both captured before this branch and always were, so the
    storage verdict that gates the FTPS sweep is untouched; two tests pin
    that, because it is the part that would actually cost archives if it
    drifted. What changes is that the diagnostic entry stops lying, and it is
    the entry that tells an operator whether their printer stores files
    somewhere Bambuddy can read -- which is the whole subject of #2780 and
    would have to count, and an undercount there would have been silent.
2026-08-17 15:47:43 +02:00
maziggy 2b38cff16d Stop a print with no 3MF borrowing another model's data (#2843)
H2-series and P2S firmware keeps a slicer-sent file on internal eMMC.
    Port 990 serves external storage only, so there is no file to fetch, and
    the print becomes an archive with no 3MF -- the ordinary outcome for
    anyone who sends from Bambu Studio rather than through Bambuddy.
    Confirmed on the maintainer's own machines: an H2C and an H2D both
    dispatched brtc://emmc for the same model one minute apart, while an X1C
    sent ftp:// for it. Three separate defects live in what happens next.

    The first is the serious one. An archive with no 3MF keeps the path the
    printer is executing as its filename, and on a sliced job that is always
    Metadata/plate_1.gcode. The fallback that looks for the same model in the
    Library or among earlier prints took its search term from there, so it
    searched for `plate_1` -- a name every Bambu print in existence has --
    and matched on a substring, so it also matched any name merely ending
    that way. On the H2D a 1.6 g Cube resolved to lid_plate_1.gcode.3mf and
    was costed at 207 g across three real spools. It was not confined to
    plate names either: in the same database `Bank.3mf` matched "Piggo the
    piggy bank", and `x1c.gcode.3mf` matched "slice-test-x1c". The matcher
    now takes the model name the printer reports when the filename is only a
    plate path, refuses a bare plate stem rather than searching for it, and
    anchors to a whole filename with LIKE metacharacters escaped, because `_`
    is a wildcard and model names are full of them. A print that cannot be
    identified is now left untracked, which is the honest answer -- the
    previous behaviour was to charge the operator's spools for a model they
    had not printed.

    Checked against every row rather than argued from the code. Across 273
    library stems the result sets are identical. Across 241 archive stems 14
    differ, all of them strictly narrower, and every dropped match is one of
    the false positives above; all 233 archives still match their own
    filename, so no legitimate donor was lost. Of the eight no-3MF archives
    on that install the old matcher picked a wrong donor for two -- one of
    them a calibration run that would have been charged the 207 g -- and the
    new one picks none.

    The second defect is that those archives could not receive a timelapse at
    all. attach_timelapse derived its destination from the missing file's
    path, and (base_dir / "").parent is the parent of base_dir, one level
    outside the data directory. In Docker that is /app, so every attempt
    failed EACCES and the scan retried and discarded the video 25 times over
    twelve minutes, roughly a hundred FTPS connections for bytes that had
    already downloaded successfully. Where that location happened to be
    writable it was worse: the file landed beside the installation and the
    attach then failed anyway, because the path could not be made relative to
    base_dir. #1820 introduced a shared helper precisely so these derivations
    could not drift apart, and this was the one site still doing it by hand.
    The directory is created only after the filename has passed the traversal
    check, so a rejected name still leaves nothing behind.

    The third is silence. When a print's filament cannot be read from a 3MF,
    the remaining-percentage delta is the fallback, and that needs a reading
    at print start -- which a spool without RFID does not have until someone
    sets a remaining amount by hand. Those slots were skipped with a bare
    continue. Every other reason for skipping a slot in that loop is logged,
    and the comment a few lines below argues the case explicitly: charging
    nothing silently is indistinguishable from having nothing to charge. It
    now says so, for slots the print actually used.

    Four existing tests needed updating rather than the production path.
    They patch backend.app.services.archive.settings by name, and the shared
    helper reads its own module-level binding, so they kept the real data
    directory and wrote outside tmp_path -- which is how the first draft of
    this change littered a working tree. They now patch both bindings.
2026-08-17 15:47:10 +02:00
maziggy f855d8dcca Pin the PostgreSQL session to UTC so defaulted timestamps are UTC (#2855)
On a UTC+3 install every AMS humidity reading and every archive was
    stamped three hours ahead of when it happened. Bambuddy stores naive
    timestamps that hold UTC and the frontend's parseUTCDate reads an
    offsetless timestamp as UTC, so the display added the offset to a value
    that was already local.

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

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

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

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

    One related mismatch goes with it, because fixing the database side alone
    would have made it start lying on exactly the installs this repairs. The
    support package's oldest_pending_age_seconds subtracted a naive local
    clock from a naive UTC column, with a comment claiming it was UTC; on the
    reporter's install the two errors cancelled. It reported a job queued
    five minutes ago as three hours old east of Greenwich and a negative age
    west of it. The two AMS and printer-sensor retention cutoffs move to the
    same utcnow_naive helper -- correct in value already, but deprecated in
    3.12 and emitting warnings on every sweep.
2026-08-17 15:46:44 +02:00
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 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 607b34e94d Check the card before writing a print off as internal-storage-only (issue #2856)
A print's dispatch says where the printer put the sliced file:
ftp://<name> for external storage, brtc://emmc/<name> for internal. Since
that there is then no file to find at any path.

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

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

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

The probe filename arrives over MQTT and becomes both a remote path and a
local temp filename, so names carrying separators, traversal or control
characters are declined rather than cleaned.
2026-08-17 14:50:40 +02:00
maziggy 5a05e03c8e Use the printer's own plug for energy when several are linked (issue #2859)
Per-print energy is one plug's meter read at the start of a print and
again at the end. Both readings asked for "the plug on this printer"
with scalar_one_or_none(), which raises on two rows. Linking a second
plug to a printer - a dry box, a filter fan, a lights script - therefore
stopped energy tracking on that printer outright, and did it silently:
the print-start handler logged the exception as an ordinary failure and
the print-end handler then reported "no start kWh recorded", which is
also what it says for a printer with nothing linked to it.

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

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

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

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

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

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

The user-facing copy shipped this morning is corrected in the same
change, because it was written before we understood how Bambu Studio
actually chooses. Its Print button always uses internal memory; only Send
offers Cache or External, and that defaults to Cache too. So the advice
now leads with the two routes that take one step -- start the print from
Bambuddy, or slice in OrcaSlicer -- and offers Send-with-External and a
separate print start as the way to stay in Bambu Studio. The earlier
wording named no remedy at all and blamed the printer's firmware for a
choice the slicer makes. Banner and diagnostic, thirteen locales, README,
bundle rebuilt.
2026-08-17 11:24:53 +02:00
maziggy 89ea337612 Recognise our own print dispatch instead of guessing at a magic number
(#2843 follow-up)

Bambuddy records the project_file behind every print, because that command
names where the sliced file was put and so decides whether the archive can
have a thumbnail and slicer metadata at all. Its own dispatches were told
apart from a slicer's by testing sequence_id against "20000", on the
belief that 20000 was Bambuddy's alone.

It never was. 20000 is the slicer convention Bambuddy copied --
virtual_printer/bind_server documents the slicer sending exactly that
during detect -- and both slicers count up from it. Measured on the wire:
OrcaSlicer dispatched 20000 and then 20001, BambuStudio 20009 and 20010.
So whichever dispatch happened to land on the shared value was filed as
ours and never recorded, and after a slicer restart that is the first
print you send. The test was wrong in the other direction too: it called
every value above 20000 external, including ones we had sent ourselves,
which only stayed invisible because we always send exactly 20000.

Ownership is now established by remembering the job actually dispatched
-- sequence id, file, url and subtask name -- and consuming that marker
on the echo. One-shot deliberately: a slicer reprint of the same file a
moment later is somebody else's print and must not hide behind our last
one.

Nothing about printing or archiving changes. current_project_url and
ams_mapping are both captured before this branch and always were, so the
storage verdict that gates the FTPS sweep is untouched; two tests pin
that, because it is the part that would actually cost archives if it
drifted. What changes is that the diagnostic entry stops lying, and it is
the entry that tells an operator whether their printer stores files
somewhere Bambuddy can read -- which is the whole subject of #2780 and
would have to count, and an undercount there would have been silent.
2026-08-17 10:26:38 +02:00
maziggy 6488a33488 Stop a print with no 3MF borrowing another model's data (#2843)
H2-series and P2S firmware keeps a slicer-sent file on internal eMMC.
Port 990 serves external storage only, so there is no file to fetch, and
the print becomes an archive with no 3MF -- the ordinary outcome for
anyone who sends from Bambu Studio rather than through Bambuddy.
Confirmed on the maintainer's own machines: an H2C and an H2D both
dispatched brtc://emmc for the same model one minute apart, while an X1C
sent ftp:// for it. Three separate defects live in what happens next.

The first is the serious one. An archive with no 3MF keeps the path the
printer is executing as its filename, and on a sliced job that is always
Metadata/plate_1.gcode. The fallback that looks for the same model in the
Library or among earlier prints took its search term from there, so it
searched for `plate_1` -- a name every Bambu print in existence has --
and matched on a substring, so it also matched any name merely ending
that way. On the H2D a 1.6 g Cube resolved to lid_plate_1.gcode.3mf and
was costed at 207 g across three real spools. It was not confined to
plate names either: in the same database `Bank.3mf` matched "Piggo the
piggy bank", and `x1c.gcode.3mf` matched "slice-test-x1c". The matcher
now takes the model name the printer reports when the filename is only a
plate path, refuses a bare plate stem rather than searching for it, and
anchors to a whole filename with LIKE metacharacters escaped, because `_`
is a wildcard and model names are full of them. A print that cannot be
identified is now left untracked, which is the honest answer -- the
previous behaviour was to charge the operator's spools for a model they
had not printed.

Checked against every row rather than argued from the code. Across 273
library stems the result sets are identical. Across 241 archive stems 14
differ, all of them strictly narrower, and every dropped match is one of
the false positives above; all 233 archives still match their own
filename, so no legitimate donor was lost. Of the eight no-3MF archives
on that install the old matcher picked a wrong donor for two -- one of
them a calibration run that would have been charged the 207 g -- and the
new one picks none.

The second defect is that those archives could not receive a timelapse at
all. attach_timelapse derived its destination from the missing file's
path, and (base_dir / "").parent is the parent of base_dir, one level
outside the data directory. In Docker that is /app, so every attempt
failed EACCES and the scan retried and discarded the video 25 times over
twelve minutes, roughly a hundred FTPS connections for bytes that had
already downloaded successfully. Where that location happened to be
writable it was worse: the file landed beside the installation and the
attach then failed anyway, because the path could not be made relative to
base_dir. #1820 introduced a shared helper precisely so these derivations
could not drift apart, and this was the one site still doing it by hand.
The directory is created only after the filename has passed the traversal
check, so a rejected name still leaves nothing behind.

The third is silence. When a print's filament cannot be read from a 3MF,
the remaining-percentage delta is the fallback, and that needs a reading
at print start -- which a spool without RFID does not have until someone
sets a remaining amount by hand. Those slots were skipped with a bare
continue. Every other reason for skipping a slot in that loop is logged,
and the comment a few lines below argues the case explicitly: charging
nothing silently is indistinguishable from having nothing to charge. It
now says so, for slots the print actually used.

Four existing tests needed updating rather than the production path.
They patch backend.app.services.archive.settings by name, and the shared
helper reads its own module-level binding, so they kept the real data
directory and wrote outside tmp_path -- which is how the first draft of
this change littered a working tree. They now patch both bindings.
2026-08-17 09:26:13 +02:00
maziggy 28b2b9f151 Pin the PostgreSQL session to UTC so defaulted timestamps are UTC (#2855)
On a UTC+3 install every AMS humidity reading and every archive was
stamped three hours ahead of when it happened. Bambuddy stores naive
timestamps that hold UTC and the frontend's parseUTCDate reads an
offsetless timestamp as UTC, so the display added the offset to a value
that was already local.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Adding the two fields to PrinterState broke four test modules whose
SimpleNamespace stubs predate them. The stubs are fixed rather than the
production reads made defensive: the real dataclass always carries both,
and a getattr in the dedup key would silently stop tracking the field if
it were ever renamed.
2026-08-16 15:09:00 +02:00
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 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 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 95e28e39ce Bumped version 2026-08-15 15:07:17 +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 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 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 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