Commit Graph
2107 Commits
Author SHA1 Message Date
maziggy 5584dca898 Bumped version 2026-09-20 13:19:02 +02:00
maziggy 0e0bea1aa7 Bumped version 2026-08-30 08:56:26 +02:00
maziggy 0dfcff5925 Keep the RTSPS proxy's handler set off the server object (issue #3001)
asyncio's Server has a __dict__ and uvloop's, a Cython cdef class, does
not, so the attribute added in 1.2.5.4 raised AttributeError under uvloop.
Every RTSP camera failed before opening a socket, which is the
diagnostic's capture_exception at 0 ms.

Our own unit files all pin --loop asyncio for #1896 and were never
affected. The reports come from units we do not write: the Proxmox VE
Helper-Scripts LXC pins no loop, and installs predating that fix never
gained the flag because update.sh does not rewrite unit files. The loop
is not ours to assume, so fix the code rather than add another flag.

The set moves to a module-level WeakKeyDictionary, keyed weakly so an
abandoned proxy retires its own entry rather than leaking one and later
handing a new server a dead one's handlers.

Pinned on a real uvloop loop and, for hosts without uvloop, against a
__slots__ server; conftest builds its loop from the default policy, so
nothing in the suite had ever run the branch that broke.

Also routes the two external-camera teardowns through close_tls_proxy,
which #2968 introduced and left them out of.

-----

Say so at startup when running on uvloop (issue #3001)

An install on the wrong loop had no way to find out it was. #3001 was
loud enough to notice; the #1896 upload truncation it is also exposed to
is silent, and shows up as a print failing from a file that was corrupt
on arrival.

One WARNING in the lifespan naming the loop, the risk and the flag to
add. A warning and not a refusal: uvicorn has already chosen its loop by
the time any application code runs, and a server that answers requests
beats one that will not boot.

Asks the running loop what it is rather than whether uvloop imports --
uvicorn[standard] installs uvloop everywhere, so its presence says
nothing -- and matches on the module name so the question never imports
uvloop on a host without it.

-----

Repair a service file written before the --loop asyncio pin (issue #3001)

install.sh has pinned the loop since #1896, but nothing has ever
rewritten an existing service file, so every native install created
between 2025-11-28 (when uvicorn[standard] brought uvloop into the venv)
and 2026-07-05 still runs on uvloop no matter how often it is updated.

Both update scripts now add the flag themselves while the service is
stopped, so it takes effect on the same restart -- systemd via sed,
launchd via PlistBuddy, each backing the file up first and inserting
nothing but the flag.

Refuses to edit and explains instead when the shape is not a plain
single-line uvicorn unit: a wrapper script, a continued ExecStart,
several of them, a read-only file, or a service with drop-ins, since a
drop-in may be what defines ExecStart and editing the fragment would
change nothing while reporting success. A deliberate --loop uvloop is
left alone. Reads the effective ExecStart from systemd rather than the
file, so it is idempotent.
2026-08-30 08:01:42 +02:00
maziggy 3f1ed85791 Housekeeping 2026-08-29 16:57:04 +02:00
maziggy 49c948d01a Pin the TLS floor on the cleartext-probe test's context
CodeQL reports the context as allowing TLS 1.0 and 1.1, and it is right
about the mechanism: create_default_context() leaves minimum_version at
MINIMUM_SUPPORTED, which is the build's floor rather than a guarantee.
That is the reason every context in backend/app pins it, the reason
bambu_ftp.py carries a comment saying so, and the reason this same file
already pins it for the TLS-1.3 case further down. Line 127 was the one
that did not.

The floor cannot change what the test measures. The fixture answers with
a plain FTP banner and speaks no TLS, so the handshake still fails as
WRONG_VERSION_NUMBER, which is the assertion this test exists to make.
2026-08-29 15:29:59 +02:00
maziggy 9d35a4e8c6 Mark four test-side bandit false positives
The PR gate reported four new alerts, all in test code. A fixture's
/tmp/x.3mf is a column value the migration under test UPDATEs, not a
path anything opens. Two f-string statements interpolate column names
from a literal list declared two lines above, with the id bound - a
column name cannot be a bind parameter, which is why it is written into
the string at all. The two joins move onto their own lines because a
trailing marker would have taken line 64 past the 120-character limit.

The fourth is a near miss rather than a finding: the line below it
already carries the marker, as do four other wildcard sites in the same
file. The wildcard is what that test exists to assert about.
2026-08-29 15:25:26 +02:00
maziggy 0eb8d4b22f Mark the failure-reason migration's table name for bandit too
The line carried a noqa for ruff's S608 but nothing bandit reads, so the
same rule was silent in one tool and reported as a medium SQL-injection
finding in the other.

Nothing is interpolated but `table`, which the loop takes from a literal
tuple on the next line; the key and the label list are both bound
parameters. A table name cannot be one, which is why it is written into
the string at all.
2026-08-29 14:47:43 +02:00
maziggy 9755e08077 Decide whether a 3MF is sliced by looking inside it (issue #2993)
An archive that showed the green GCODE badge could re-import into the
    File Manager as a source-only project with no Print button, seemingly at
    random.

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

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

    The Slice action moves with it. Its refusal to slice an output was as
    name-bound as the Print gate, and without that a file that correctly
    gained a Print button would have offered to re-slice its own G-code.
2026-08-29 14:21:46 +02:00
maziggy f616a6bca9 Show the spool that is in the AMS slot, not the one that was
Pull a Bambu ABS Orange out of A1, put a PLA Matte Dark Blue in, and the
    slot card still read "Bambu ABS" against the new colour until the page
    was reloaded.

    Three things stood between the swap and a correct card.

    The RFID auto-assign rewrites the slot's slot_preset_mappings row and
    then broadcast an event that refreshed everything except the query that
    reads it. Only the manual assign path invalidated that one.

    Those queries then sat behind the 3s cascade debounce, which exists for
    print completion, where one event fans out across half the app. A swap
    touches one slot and the user is standing at the printer looking at the
    card; worse, the timer restarts on every further event, so a busy moment
    could defer it indefinitely. Slot changes now invalidate immediately.

    And the card trusted the stored preset over live telemetry outright.
    That priority is why a hand-picked preset name stays on a slot, but it
    also let a cached row outrank what the printer was reporting. The row is
    now skipped when it names a different official Bambu filament than the
    tray does, so the card is right from the status push alone. User and
    local presets carry ids that genuinely cannot be compared and are left
    exactly as they were.

    Spoolman mode was the worse half of the same bug: its AMS sync writes
    the same row but announced nothing at all, so there was no event to
    refresh on. It now reports each slot it changed or cleared.
2026-08-29 14:21:08 +02:00
maziggy 049afea980 Refuse a same-named 3MF that contradicts the running print (issue #2957)
When a print's own 3MF cannot be fetched the usage tracker borrows one from the
    library or a previous archive, matching on the filename stem. That is far weaker
    evidence than it looks: Bambu Studio writes the printer-side filename from the
    project's Title metadata, so every plate of a project reaches the printer under
    one name however the file was renamed on disk.

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

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

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

    -----

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

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

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

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

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

    -----

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

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

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

    A file picked up that way is left alone rather than re-registered under this
    endpoint's own name or deleted on the way out. It is the archive flow's.
2026-08-29 14:20:15 +02:00
maziggy 0eafe312c6 Repair the bed temperature on archives written before the fix (issue #2989)
The forward fix reads the array the fitted plate points at, but only for
    archives made after it. Everything already in the library stays blank, and
    preheat keeps falling back to the keep-warm bed temperature whenever one of
    those jobs is reprinted from the queue - 0 of 455 real 3MFs had resolved.

    A one-shot pass re-reads the 3MF already on disk, gated by a settings flag the
    way #2614's repair is: the rows it cannot fill are exactly the ones it would
    reopen every boot. It fills NULLs only. Nothing recorded is overwritten, an
    archive whose file is gone stays NULL, and a corrupted 3MF is skipped rather
    than failing startup.

    The plate mapping moves to threemf_tools.bed_temperature_from_config so the
    ingest path and the repair cannot read a 3MF differently - the same drift
    move.

    _extract_settings_from_content is deleted. Nothing called it anywhere in the
    repo, and it carried the old bed_temperature mapping this issue fixed.
2026-08-29 14:19:49 +02:00
maziggy 20627b2188 Log ffmpeg's error instead of its build banner
ffmpeg opens every run with ~20 lines of version and build banner and prints
    its diagnosis last, so the stderr[:200] eight of the nine call sites used kept
    the banner and threw the error away. The reporter's twelve capture failures all
    read "ffmpeg version 7.1.4 ... configuration: --prefix=/usr --extra-version=",
    identical on every install; the exit code was the only usable byte.

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

    -----

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

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

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

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

    -----

    Stop the RTSPS proxy leaving a handler behind at shutdown

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

    Handlers are held for as long as they run and cancelled at shutdown, which is
    Server.close_clients() by hand -- that landed in 3.13 and Bambuddy supports
    3.10. Both the snapshot path and the streaming endpoint share the shutdown.
2026-08-29 14:19:22 +02:00
maziggy ff9b956156 Read the bed temperature from the plate the project is sliced for (issue #2989)
BambuStudio writes no bed_temperature key. It stores a per-filament array per
    plate type and names the fitted plate in curr_bed_type; bed_temperature is the
    Orca/Prusa spelling, so the lookup matched nothing and every archive from a
    Bambu slice stored NULL - 0 of 455 real 3MFs resolved. Preheat then fell back
    to the keep-warm bed temperature on jobs that had one all along.

    Plate names and the plate-to-key mapping are BambuStudio's own get_bed_temp_key
    and get_bed_temp_1st_layer_key. First-layer value preferred, highest entry in
    the per-filament array taken, an all-zero array left unrecorded.
2026-08-29 14:19:02 +02:00
maziggy ffc55b6a6e Skip the manual K calibration line as an internal printer job (issue #2957 follow-up)
Manual flow dynamics has two shapes and each reports under its own name with
    no auto_ prefix. pa_pattern_calib_mode was already filtered; pa_line_calib_mode
    was not, so it still swept FTP for a 3MF that cannot exist and wrote a no-3MF
    archive named after the calibration.
2026-08-29 14:18:43 +02:00
maziggy 2e405afcd1 Decide whether a 3MF is sliced by looking inside it (issue #2993)
An archive that showed the green GCODE badge could re-import into the
File Manager as a source-only project with no Print button, seemingly at
random.

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

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

The Slice action moves with it. Its refusal to slice an output was as
name-bound as the Print gate, and without that a file that correctly
gained a Print button would have offered to re-slice its own G-code.
2026-08-29 14:14:26 +02:00
maziggy 7363d5fd33 Show the spool that is in the AMS slot, not the one that was
Pull a Bambu ABS Orange out of A1, put a PLA Matte Dark Blue in, and the
slot card still read "Bambu ABS" against the new colour until the page
was reloaded.

Three things stood between the swap and a correct card.

The RFID auto-assign rewrites the slot's slot_preset_mappings row and
then broadcast an event that refreshed everything except the query that
reads it. Only the manual assign path invalidated that one.

Those queries then sat behind the 3s cascade debounce, which exists for
print completion, where one event fans out across half the app. A swap
touches one slot and the user is standing at the printer looking at the
card; worse, the timer restarts on every further event, so a busy moment
could defer it indefinitely. Slot changes now invalidate immediately.

And the card trusted the stored preset over live telemetry outright.
That priority is why a hand-picked preset name stays on a slot, but it
also let a cached row outrank what the printer was reporting. The row is
now skipped when it names a different official Bambu filament than the
tray does, so the card is right from the status push alone. User and
local presets carry ids that genuinely cannot be compared and are left
exactly as they were.

Spoolman mode was the worse half of the same bug: its AMS sync writes
the same row but announced nothing at all, so there was no event to
refresh on. It now reports each slot it changed or cleared.
2026-08-29 13:26:09 +02:00
maziggy 7c10412f99 Refuse a same-named 3MF that contradicts the running print (issue #2957)
When a print's own 3MF cannot be fetched the usage tracker borrows one from the
library or a previous archive, matching on the filename stem. That is far weaker
evidence than it looks: Bambu Studio writes the printer-side filename from the
project's Title metadata, so every plate of a project reaches the printer under
one name however the file was renamed on disk.

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

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

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

-----

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

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

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

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

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

-----

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

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

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

A file picked up that way is left alone rather than re-registered under this
endpoint's own name or deleted on the way out. It is the archive flow's.
2026-08-29 10:46:36 +02:00
maziggy b0ecb8fd88 Repair the bed temperature on archives written before the fix (issue #2989)
The forward fix reads the array the fitted plate points at, but only for
archives made after it. Everything already in the library stays blank, and
preheat keeps falling back to the keep-warm bed temperature whenever one of
those jobs is reprinted from the queue - 0 of 455 real 3MFs had resolved.

A one-shot pass re-reads the 3MF already on disk, gated by a settings flag the
way #2614's repair is: the rows it cannot fill are exactly the ones it would
reopen every boot. It fills NULLs only. Nothing recorded is overwritten, an
archive whose file is gone stays NULL, and a corrupted 3MF is skipped rather
than failing startup.

The plate mapping moves to threemf_tools.bed_temperature_from_config so the
ingest path and the repair cannot read a 3MF differently - the same drift
move.

_extract_settings_from_content is deleted. Nothing called it anywhere in the
repo, and it carried the old bed_temperature mapping this issue fixed.
2026-08-29 09:23:48 +02:00
maziggy d4477e9b71 Log ffmpeg's error instead of its build banner
ffmpeg opens every run with ~20 lines of version and build banner and prints
its diagnosis last, so the stderr[:200] eight of the nine call sites used kept
the banner and threw the error away. The reporter's twelve capture failures all
read "ffmpeg version 7.1.4 ... configuration: --prefix=/usr --extra-version=",
identical on every install; the exit code was the only usable byte.

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

-----

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

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

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

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

-----

Stop the RTSPS proxy leaving a handler behind at shutdown

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

Handlers are held for as long as they run and cancelled at shutdown, which is
Server.close_clients() by hand -- that landed in 3.13 and Bambuddy supports
3.10. Both the snapshot path and the streaming endpoint share the shutdown.
2026-08-29 08:51:33 +02:00
maziggy c001f596bf Read the bed temperature from the plate the project is sliced for (issue #2989)
BambuStudio writes no bed_temperature key. It stores a per-filament array per
plate type and names the fitted plate in curr_bed_type; bed_temperature is the
Orca/Prusa spelling, so the lookup matched nothing and every archive from a
Bambu slice stored NULL - 0 of 455 real 3MFs resolved. Preheat then fell back
to the keep-warm bed temperature on jobs that had one all along.

Plate names and the plate-to-key mapping are BambuStudio's own get_bed_temp_key
and get_bed_temp_1st_layer_key. First-layer value preferred, highest entry in
the per-filament array taken, an all-zero array left unrecorded.
2026-08-28 14:26:34 +02:00
maziggy 164382b38b Skip the manual K calibration line as an internal printer job (issue #2957 follow-up)
Manual flow dynamics has two shapes and each reports under its own name with
no auto_ prefix. pa_pattern_calib_mode was already filtered; pa_line_calib_mode
was not, so it still swept FTP for a 3MF that cannot exist and wrote a no-3MF
archive named after the calibration.
2026-08-28 13:30:27 +02:00
MartinNYHC 8cce10b60a Skip the manual K calibration the way the automatic one is skipped
Bambuddy already recognises the printer's automatic pressure-advance run,
    auto_pa_line_calib_mode, and leaves no archive and sends no notification
    for it. Started by hand rather than automatically before a print, the
    same calibration reports under a different name: it prints a pattern
    where the automatic one prints a line, and carries no auto_ prefix, so
    pa_pattern_calib_mode matched nothing.

    It arrives exactly the way the automatic one does -- a bare subtask name
    with no /usr/ path -- so it hit the same outcome the module was written
    to prevent: an FTP sweep for a 3MF that cannot exist, six candidate names
    across five directories with retries, and then a no-3MF archive named
    after the calibration, on a printer in the middle of calibrating.

    One entry on INTERNAL_JOB_NAMES covers it. That set is the single place
    both the print-start and print-complete callbacks consult, so archiving,
    the 3MF sweep and the notifications are all handled by the one line.

    Matching stays exact after normalising path, suffix and case. The
    negative cases are extended alongside the positive ones, so a file
    somebody deliberately named pa_pattern_calib_mode_v2.3mf is still
    archived as the print it is.

    The manual PA *line* method, if it reports its own name, is not covered
    here -- the list is deliberately limited to names that have actually been
    observed rather than ones that seem likely.
2026-08-28 12:45:17 +02:00
MartinNYHC 9b83e8eba4 Send AMS tray colours as uppercase hex (issue #2987)
Assigning a spool to an AMS slot unassigned it again seconds later, and
    the slot's colour changed at the same time. It presented as Bambu Studio
    and Bambuddy fighting over the slot. The reporter's log shows Bambuddy
    losing to itself.

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

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

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

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

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

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

    Two more defects from the same log.

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

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

    The handshake failures themselves are printer-side FTPS cool-off under
    load and are not touched here.
2026-08-28 12:44:49 +02:00
MartinNYHC 450f9a6f20 Derive the chamber target from the trays the print loads (issue #2886)
Preheat took the maximum chamber target across every loaded AMS tray,
    with no reference to the job. The reporter's P2S holds PETG Pro, PLA,
    ASA and PETG; the ASA row of the filament map says 45C, so a PLA-only
    plate was dispatched with chamber_target=45C, the bed driven to 90C to
    reach it, and the full 900s max-wait plus 300s soak burned before the
    upload started. Every time -- a P2S has no chamber heater and its
    chamber tops out around 33C, so the wait can only ever end on the
    timeout. Their log carries fifteen of these.

    The intent was never in doubt. The resolution order documented one
    screen above _derive_chamber_target reads "PLA-only print derives 0 ->
    chamber phase auto-skips", but it was implemented as PLA-only AMS
    rather than PLA-only print, and only misfires on a mixed load.

    The derivation now reads the trays the item's ams_mapping names -- the
    same array the print command puts on the wire, [-1, -1, -1, 1] in their
    case, addressing exactly the PLA slot -- so the ASA two slots over
    contributes nothing and the stage skips outright. Multi-material prints
    are unaffected: the maximum is still taken, across the trays the plate
    actually loads, so an ASA the print does use is still binding.

    An item whose mapping is missing or still unresolved keeps the
    whole-unit scan. That is the only signal left, and narrowing to nothing
    would disable preheat for prints that need it -- the failure mode worth
    avoiding here is the silent one.

    The bed hold between jobs is gated on the same derivation and was
    holding beds at 90C for the same wrong reason. It now reads the next
    item's mapping too, where that item has one.

    The external spool is no longer invisible to this. The scan only ever
    looked at raw_data['ams'], so an ASA print fed from the external feed
    derived 0 and got no preheat at all; a mapping naming 254/255 is now
    honoured. An item with no mapping still derives from the AMS alone, so
    nothing starts preheating that did not before.

    Tray addressing matches _build_loaded_filaments, which is what produced
    the ids in the mapping being read back: ams_id * 4 + tray_id, the bare
    unit id for an AMS-HT from 128, and the firmware's own vt_tray id for
    an external feed. Ids are coerced because this firmware reports them as
    strings.
2026-08-28 12:44:24 +02:00
MartinNYHC 21fd476668 Ask Spoolman which extra fields it has, once (issue #2983)
Bambuddy checked whether one of its four custom spool fields existed with
    GET /field/spool/{name}. Spoolman has never served that. Its API declares
    only POST and DELETE at that path -- confirmed against the live server's
    own OpenAPI document -- so the probe answered 405 Method Not Allowed
    every time and the check could not succeed on any version.

    Every call therefore fell through to POST /field/spool/{name}, and that
    endpoint is an upsert rather than a create. It answers 200 whether or not
    the field is already there, so a field the user had renamed, retyped or
    given a default to in Spoolman's own UI was reset to Bambuddy's version
    of it, and an untrue "Created Spoolman extra field" was logged beside it.
    The reporter's log carried 60 of those lines over three days -- once per
    field per client init, which is every restart and every settings save.

    Existence now comes from GET /field/spool, the listing endpoint, matched
    on each row's `key`. Matching on `key` rather than the display `name` is
    the part that fixes the overwrite: a renamed field is the same field, and
    reading it as a missing one is what re-created it. A field that already
    exists is now left completely alone.

    The listing is read once per client and banked, so registering all four
    fields costs one request instead of four, and a client that has already
    looked makes none at all. Only a successful read is banked -- a client
    that could not reach the listing asks again for the next field it has not
    seen, so one transient failure does not leave it posting blind, and
    overwriting, for the rest of its life.

    An unreadable listing still falls back to attempting the POST. Registration
    is best-effort by contract: it must not turn a write that might still
    succeed into one that never happens, so an unexpected Spoolman build is no
    worse off than before.

    Measured against Spoolman 0.23.1: a field renamed to "Bambu RFID Tag"
    survives a full registration pass that previously reset it, the pass makes
    one GET and one POST for the single genuinely-missing field where it used
    to make four POSTs, and a second pass on the same client makes no requests
    at all.

    The fake in test_spoolman_extra_field_registration_2903 modelled the
    per-field path as a working probe, which is the assumption this bug was
    built on; it now answers 405 as the real server does, and its assertions
    follow the listing. 18 new tests cover the rest, 10 of which fail against
    the old code.
2026-08-28 12:43:58 +02:00
MartinNYHC 112e58e5b9 Match slicer presets on what they declare, not what they are named (issue #2982)
The internal slicer picked PETG for a PLA plate and an A1 process for a
    P1S. Both come from the sidecar's bundled-profile listing, fixed in the
    sidecar repo; this is the consuming half plus the hardening that keeps an
    older sidecar degrading rather than breaking.

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

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

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

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

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

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

    Verified against live Orca 2.4.2 and BambuStudio 02.08.02.61 sidecars
    over the real 1156- and 1792-profile trees: every one of the eight
    printer models tested now auto-picks a 0.20mm process for its own
    printer, a PLA plate draws a PLA preset and a PETG plate a PETG one.
    Each change was confirmed to fail its tests when reverted.
2026-08-28 12:43:29 +02:00
MartinNYHC 26a827ea94 Name an unnamed print stage "Preparing" on the card
New printers report stage numbers before Bambuddy learns their names,
    and the H2C still has several. Those reached the printer card verbatim,
    as "Unknown stage (72)" -- a number that means nothing to the person
    reading it, on the one line that otherwise says what the printer is
    doing.

    Every stage that has turned out to be unnamed so far has been part of
    the run-up to printing, so an unnamed one now reads as "Preparing".
    That is the same literal stage 74 already carries rather than a second
    spelling of the same idea, so a card cannot show two different words
    for the same situation depending on which number the firmware picked.

    Display only, and deliberately not pushed down into get_stage_name.
    That function also feeds the stage-transition log line and the
    once-per-session warning added to capture unnamed stages so they can be
    named in a later release; there the number is the entire diagnostic
    value, and replacing it with "Preparing" would hide the only thing that
    reports these. Both paths are pinned by tests asserting they disagree
    for an unnamed stage and agree for a named one, so a later tidy-up
    cannot quietly collapse them.

    The idle sentinels are untouched: 255 on A1/P1 and -1 on X1 mean "no
    stage", not an unnamed one, and still resolve to nothing rather than
    being swept up by the fallback.

    Nothing keys logic off stg_cur_name -- it is display-only in the printer
    card, the print dialog's printer selector and the stream overlay -- so
    all three improve and none change behaviour. No i18n either way: the
    whole stage table has always been English.
2026-08-28 12:43:09 +02:00
MartinNYHC 2d0eca9354 Paint an AMS slot card with the spool's colours, not the tray's (issue #2967)
A Ziro "Colorful Mist" -- yellow, cyan and pink, effect Tri Color --
    hovered on the printer card as a single flat pink rectangle. A printer
    reports exactly one tray_color hex per tray and nothing else, so
    telemetry cannot describe a gradient or a surface effect and never will.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    The last part is a data-loss bug that was not in the report. The editor's
    fallback to '' was not merely a wrong-looking dropdown -- the empty
    selection was then saved over the stored text, so opening the editor on
    an archive whose reason was free text and pressing Save destroyed the
    classification. An unrecognised value now keeps its own option and
    survives a save.
2026-08-28 12:41:46 +02:00
MartinNYHC 012d4df3a5 Name an AMS slot after the spool assigned to it
The print dialog described every slot from the printer's own telemetry, and
    a printer cannot describe a spool it did not sell: a tray record carries no
    brand field, tray_sub_brands is left empty for anything that is not a Bambu
    spool, and the colour arrives as a bare hex the client resolves against
    Bambu's own colour catalogue. A Devil Design PLA Basic Orange assigned in
    Bambuddy therefore read as "PLA (Sunflower Yellow)" -- Bambu sell a
    Sunflower Yellow at the same FEC600 -- while the printer card, which reads
    the assignment, named it correctly. Two views of one slot, disagreeing.

    GET /printers/{id}/inventory-remain now carries each bound slot's brand,
    material, subtype, colour name and hex alongside the pooling key it already
    sent, and the dialog prefers that over telemetry. The fallback is per field,
    not all or nothing, so a spool with no stored colour name still gets the
    catalogue lookup it had before while its brand and subtype come from the
    binding. Resolved server-side because the identity rule differs per
    inventory mode -- brand is a column in internal mode and a nested vendor in
    Spoolman's, where the subtype is the filament name with its material prefix
    stripped and the colour name has a three-step read order Spoolman has no
    field for. Spoolman's synthesised colour name, which falls back to the
    subtype, is withheld rather than rendered as "PLA Basic (Basic)".

    Matching is deliberately untouched and still runs on the printer's
    telemetry. The auto-assignment, the colour-mismatch test and the mapping
    that actually gets dispatched all read type, colour hex and tray_info_idx,
    so renaming a slot cannot make the panel and the dispatcher draw different
    conclusions from it. The payload is re-read on every open of the dialog: it
    names the slots now, and a spool assigned moments earlier would otherwise
    keep its old name for the rest of the thirty-second stale window. Done at
    the two readers rather than by invalidating the key from each of the
    eighteen places a binding or a spool can change, half of which are internal
    paths and half Spoolman ones -- covering some would make freshness depend on
    which mode you run.

    Two hardening fixes fall out of putting a mapper on this path.
    build_slot_materials runs before every queue start through
    compute_deficit_for_queue_item, and _map_spoolman_spool walks a dozen nested
    fields off the wire, any of which arriving as the wrong type raises
    AttributeError rather than ValueError. Naming a slot must never cost a
    dispatch, so that call fails soft to no name. The same inputs also reached
    _material_identity_spoolman and _normalize_color_for_id, which have always
    been on this path and would fail a queue start on a Spoolman record whose
    filament is not a dict or whose color_hex is a number; both now read those
    as "nothing to pool with". Behaviour for well-formed input is unchanged --
    the guards only intercept types that previously raised -- so no pooling key
    moves and AMS Filament Backup is untouched.
2026-08-28 12:38:21 +02:00
MartinNYHC 2253afbf45 Key a K profile on its nozzle's flow type
A printer files each calibration under a nozzle id of the form HH00-0.4
    (high flow) or HS00-0.4 (standard) and can hold both for one diameter -- a
    maintainer's H2D carries 102 high-flow entries against 6 standard --
    because the same filament reads a different K through each. Nothing read
    that, so a standard-flow profile could be selected for a high-flow nozzle
    and vice versa.

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

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

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

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

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

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

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

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

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

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

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

    The Configure AMS Slot dialog opens on the spool's own configured values,
    falling back to the slot's last manual configuration and then the tray's
    RFID data. The spool form is wider for the two-pane layout, colour, weight,
    cost and location move to their own tab in two columns, and a printer card
    in expanded view lists every fitted nozzle size rather than the first entry
    alone.
2026-08-28 12:30:51 +02:00
MartinNYHC 56f26cb013 Give a no-3MF archive the timelapse baseline it never took (issue #2957)
_capture_timelapse_baseline_at_start says in its own docstring that it must
    be called from every on_print_start path that proceeds to a real print, and
    what breaks otherwise: the completion scan falls back to snapshotting the
    card after the printer has written the video, so the new file lands inside
    the baseline and no diff can ever match. There are three such paths. The
    fallback branch was not calling it, and nothing else covered the gap --
    on_print_running_observed is restart-recovery only and is suppressed
    whenever on_print_start fires. Every no-3MF archive therefore reached
    completion with no baseline in memory and none on the row, and kept its
    timelapse only by accident.

    Not confined to the reported cool-off. The same branch serves the
    internal-storage verdict, so every H2C/H2D/P2S print that Bambu Studio's
    Print button sends to eMMC lost its timelapse the same way, off a card that
    was holding it the whole time. Measured on a live install: 0 of 9 fallback
    archives had a baseline, against 73 of 275 normal ones.

    The branch now takes one like the other two, and last like they are: it
    lists the printer's timelapse directory, so a slow card must not delay the
    active-print registration, the energy reading, the archive-created event or
    the start notification ahead of it.

    The other half is a print shorter than the five-minute cool-off, where the
    card is still unreadable at the one moment a baseline has to be taken.
    list_files_async answers [] when its connect fails rather than raising, so
    that is indistinguishable from a card holding no videos. When the cool-off
    expired inside the 900-second poll window every video on the card read as
    new, the first in listing order won, and a stale unclaimed video was
    attached to the print and then deleted off the printer.

    The empty baseline is still recorded rather than refused. Bambuddy deletes
    each video once it is attached, so the usual card holds exactly one at
    completion and an empty baseline resolves it correctly; refusing outright
    would lose that common case to protect a rare one, and persisting NULL
    instead would send completion to snapshot a card that by then has this
    print's video on it. Instead the scan marks such a baseline untrusted and
    the attach step declines to choose between several candidates, leaving them
    for the manual Scan for Timelapse button. require_unambiguous defaults to
    off, so the only caller whose behaviour changes is that scan.
2026-08-28 12:27:05 +02:00
MartinNYHC 03d8310cf0 Ask which nozzle to feed when a Filament Track Switch is fitted
Load and Unload in the AMS slot menu did nothing on an H2C with the switch
    fitted. The ams_change_filament command carries an optional extruder_id and
    Bambuddy never sent it. That is correct on every printer without the switch,
    and is what BambuStudio does there too -- each AMS is wired to one hotend, so
    the firmware works the target out for itself and an explicit value would only
    be a guess at something it already knows. Fit the switch and every AMS is
    bound to one of its two inlets instead, either hotend is reachable from any
    slot, and a command naming neither leaves the firmware nothing to act on. It
    was discarded in silence.

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

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

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

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

    Two known stragglers, both deliberately left alone. Load on an AMS-HT slot
    has never worked -- an HT unit is addressed by its unit id rather than
    ams*4+slot, which these endpoints do not accept -- so unload there keeps the
    printer-wide form it always used instead of gaining a slot it cannot name.
    And a slot's K-profile still follows the AMS's plumbing rather than the
    nozzle just loaded, so loading to the far hotend leaves the other one's
    calibration bound; that is the same per-nozzle problem the filament and
    K-profile redesign is scoped to fix.
2026-08-28 12:26:05 +02:00
maziggy a4a1f4c58b Skip the manual K calibration the way the automatic one is skipped
Bambuddy already recognises the printer's automatic pressure-advance run,
auto_pa_line_calib_mode, and leaves no archive and sends no notification
for it. Started by hand rather than automatically before a print, the
same calibration reports under a different name: it prints a pattern
where the automatic one prints a line, and carries no auto_ prefix, so
pa_pattern_calib_mode matched nothing.

It arrives exactly the way the automatic one does -- a bare subtask name
with no /usr/ path -- so it hit the same outcome the module was written
to prevent: an FTP sweep for a 3MF that cannot exist, six candidate names
across five directories with retries, and then a no-3MF archive named
after the calibration, on a printer in the middle of calibrating.

One entry on INTERNAL_JOB_NAMES covers it. That set is the single place
both the print-start and print-complete callbacks consult, so archiving,
the 3MF sweep and the notifications are all handled by the one line.

Matching stays exact after normalising path, suffix and case. The
negative cases are extended alongside the positive ones, so a file
somebody deliberately named pa_pattern_calib_mode_v2.3mf is still
archived as the print it is.

The manual PA *line* method, if it reports its own name, is not covered
here -- the list is deliberately limited to names that have actually been
observed rather than ones that seem likely.
2026-08-28 12:24:39 +02:00
maziggy 0d21239e18 Send AMS tray colours as uppercase hex (issue #2987)
Assigning a spool to an AMS slot unassigned it again seconds later, and
the slot's colour changed at the same time. It presented as Bambu Studio
and Bambuddy fighting over the slot. The reporter's log shows Bambuddy
losing to itself.

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

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

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

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

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

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

Two more defects from the same log.

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

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

The handshake failures themselves are printer-side FTPS cool-off under
load and are not touched here.
2026-08-28 12:18:27 +02:00
maziggy 28d386f766 Derive the chamber target from the trays the print loads (issue #2886)
Preheat took the maximum chamber target across every loaded AMS tray,
with no reference to the job. The reporter's P2S holds PETG Pro, PLA,
ASA and PETG; the ASA row of the filament map says 45C, so a PLA-only
plate was dispatched with chamber_target=45C, the bed driven to 90C to
reach it, and the full 900s max-wait plus 300s soak burned before the
upload started. Every time -- a P2S has no chamber heater and its
chamber tops out around 33C, so the wait can only ever end on the
timeout. Their log carries fifteen of these.

The intent was never in doubt. The resolution order documented one
screen above _derive_chamber_target reads "PLA-only print derives 0 ->
chamber phase auto-skips", but it was implemented as PLA-only AMS
rather than PLA-only print, and only misfires on a mixed load.

The derivation now reads the trays the item's ams_mapping names -- the
same array the print command puts on the wire, [-1, -1, -1, 1] in their
case, addressing exactly the PLA slot -- so the ASA two slots over
contributes nothing and the stage skips outright. Multi-material prints
are unaffected: the maximum is still taken, across the trays the plate
actually loads, so an ASA the print does use is still binding.

An item whose mapping is missing or still unresolved keeps the
whole-unit scan. That is the only signal left, and narrowing to nothing
would disable preheat for prints that need it -- the failure mode worth
avoiding here is the silent one.

The bed hold between jobs is gated on the same derivation and was
holding beds at 90C for the same wrong reason. It now reads the next
item's mapping too, where that item has one.

The external spool is no longer invisible to this. The scan only ever
looked at raw_data['ams'], so an ASA print fed from the external feed
derived 0 and got no preheat at all; a mapping naming 254/255 is now
honoured. An item with no mapping still derives from the AMS alone, so
nothing starts preheating that did not before.

Tray addressing matches _build_loaded_filaments, which is what produced
the ids in the mapping being read back: ams_id * 4 + tray_id, the bare
unit id for an AMS-HT from 128, and the firmware's own vt_tray id for
an external feed. Ids are coerced because this firmware reports them as
strings.
2026-08-28 11:22:03 +02:00
maziggy 4d2c6debf7 Ask Spoolman which extra fields it has, once (issue #2983)
Bambuddy checked whether one of its four custom spool fields existed with
GET /field/spool/{name}. Spoolman has never served that. Its API declares
only POST and DELETE at that path -- confirmed against the live server's
own OpenAPI document -- so the probe answered 405 Method Not Allowed
every time and the check could not succeed on any version.

Every call therefore fell through to POST /field/spool/{name}, and that
endpoint is an upsert rather than a create. It answers 200 whether or not
the field is already there, so a field the user had renamed, retyped or
given a default to in Spoolman's own UI was reset to Bambuddy's version
of it, and an untrue "Created Spoolman extra field" was logged beside it.
The reporter's log carried 60 of those lines over three days -- once per
field per client init, which is every restart and every settings save.

Existence now comes from GET /field/spool, the listing endpoint, matched
on each row's `key`. Matching on `key` rather than the display `name` is
the part that fixes the overwrite: a renamed field is the same field, and
reading it as a missing one is what re-created it. A field that already
exists is now left completely alone.

The listing is read once per client and banked, so registering all four
fields costs one request instead of four, and a client that has already
looked makes none at all. Only a successful read is banked -- a client
that could not reach the listing asks again for the next field it has not
seen, so one transient failure does not leave it posting blind, and
overwriting, for the rest of its life.

An unreadable listing still falls back to attempting the POST. Registration
is best-effort by contract: it must not turn a write that might still
succeed into one that never happens, so an unexpected Spoolman build is no
worse off than before.

Measured against Spoolman 0.23.1: a field renamed to "Bambu RFID Tag"
survives a full registration pass that previously reset it, the pass makes
one GET and one POST for the single genuinely-missing field where it used
to make four POSTs, and a second pass on the same client makes no requests
at all.

The fake in test_spoolman_extra_field_registration_2903 modelled the
per-field path as a working probe, which is the assumption this bug was
built on; it now answers 405 as the real server does, and its assertions
follow the listing. 18 new tests cover the rest, 10 of which fail against
the old code.
2026-08-28 10:46:40 +02:00
maziggy e9daa2124e Match slicer presets on what they declare, not what they are named (issue #2982)
The internal slicer picked PETG for a PLA plate and an A1 process for a
P1S. Both come from the sidecar's bundled-profile listing, fixed in the
sidecar repo; this is the consuming half plus the hardening that keeps an
older sidecar degrading rather than breaking.

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

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

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

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

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

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

Verified against live Orca 2.4.2 and BambuStudio 02.08.02.61 sidecars
over the real 1156- and 1792-profile trees: every one of the eight
printer models tested now auto-picks a 0.20mm process for its own
printer, a PLA plate draws a PLA preset and a PETG plate a PETG one.
Each change was confirmed to fail its tests when reverted.
2026-08-28 10:30:32 +02:00
maziggy 73e0787bfd Name an unnamed print stage "Preparing" on the card
New printers report stage numbers before Bambuddy learns their names,
and the H2C still has several. Those reached the printer card verbatim,
as "Unknown stage (72)" -- a number that means nothing to the person
reading it, on the one line that otherwise says what the printer is
doing.

Every stage that has turned out to be unnamed so far has been part of
the run-up to printing, so an unnamed one now reads as "Preparing".
That is the same literal stage 74 already carries rather than a second
spelling of the same idea, so a card cannot show two different words
for the same situation depending on which number the firmware picked.

Display only, and deliberately not pushed down into get_stage_name.
That function also feeds the stage-transition log line and the
once-per-session warning added to capture unnamed stages so they can be
named in a later release; there the number is the entire diagnostic
value, and replacing it with "Preparing" would hide the only thing that
reports these. Both paths are pinned by tests asserting they disagree
for an unnamed stage and agree for a named one, so a later tidy-up
cannot quietly collapse them.

The idle sentinels are untouched: 255 on A1/P1 and -1 on X1 mean "no
stage", not an unnamed one, and still resolve to nothing rather than
being swept up by the fallback.

Nothing keys logic off stg_cur_name -- it is display-only in the printer
card, the print dialog's printer selector and the stream overlay -- so
all three improve and none change behaviour. No i18n either way: the
whole stage table has always been English.
2026-08-28 09:51:14 +02:00
maziggy 699fc419fe Paint an AMS slot card with the spool's colours, not the tray's (issue #2967)
A Ziro "Colorful Mist" -- yellow, cyan and pink, effect Tri Color --
hovered on the printer card as a single flat pink rectangle. A printer
reports exactly one tray_color hex per tray and nothing else, so
telemetry cannot describe a gradient or a surface effect and never will.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The last part is a data-loss bug that was not in the report. The editor's
fallback to '' was not merely a wrong-looking dropdown -- the empty
selection was then saved over the stored text, so opening the editor on
an archive whose reason was free text and pressing Save destroyed the
classification. An unrecognised value now keeps its own option and
survives a save.
2026-08-28 08:28:27 +02:00
maziggy d5c7047765 Name an AMS slot after the spool assigned to it
The print dialog described every slot from the printer's own telemetry, and
a printer cannot describe a spool it did not sell: a tray record carries no
brand field, tray_sub_brands is left empty for anything that is not a Bambu
spool, and the colour arrives as a bare hex the client resolves against
Bambu's own colour catalogue. A Devil Design PLA Basic Orange assigned in
Bambuddy therefore read as "PLA (Sunflower Yellow)" -- Bambu sell a
Sunflower Yellow at the same FEC600 -- while the printer card, which reads
the assignment, named it correctly. Two views of one slot, disagreeing.

GET /printers/{id}/inventory-remain now carries each bound slot's brand,
material, subtype, colour name and hex alongside the pooling key it already
sent, and the dialog prefers that over telemetry. The fallback is per field,
not all or nothing, so a spool with no stored colour name still gets the
catalogue lookup it had before while its brand and subtype come from the
binding. Resolved server-side because the identity rule differs per
inventory mode -- brand is a column in internal mode and a nested vendor in
Spoolman's, where the subtype is the filament name with its material prefix
stripped and the colour name has a three-step read order Spoolman has no
field for. Spoolman's synthesised colour name, which falls back to the
subtype, is withheld rather than rendered as "PLA Basic (Basic)".

Matching is deliberately untouched and still runs on the printer's
telemetry. The auto-assignment, the colour-mismatch test and the mapping
that actually gets dispatched all read type, colour hex and tray_info_idx,
so renaming a slot cannot make the panel and the dispatcher draw different
conclusions from it. The payload is re-read on every open of the dialog: it
names the slots now, and a spool assigned moments earlier would otherwise
keep its old name for the rest of the thirty-second stale window. Done at
the two readers rather than by invalidating the key from each of the
eighteen places a binding or a spool can change, half of which are internal
paths and half Spoolman ones -- covering some would make freshness depend on
which mode you run.

Two hardening fixes fall out of putting a mapper on this path.
build_slot_materials runs before every queue start through
compute_deficit_for_queue_item, and _map_spoolman_spool walks a dozen nested
fields off the wire, any of which arriving as the wrong type raises
AttributeError rather than ValueError. Naming a slot must never cost a
dispatch, so that call fails soft to no name. The same inputs also reached
_material_identity_spoolman and _normalize_color_for_id, which have always
been on this path and would fail a queue start on a Spoolman record whose
filament is not a dict or whose color_hex is a number; both now read those
as "nothing to pool with". Behaviour for well-formed input is unchanged --
the guards only intercept types that previously raised -- so no pooling key
moves and AMS Filament Backup is untouched.
2026-08-27 16:04:41 +02:00
maziggy e5a18bf58b Key a K profile on its nozzle's flow type
A printer files each calibration under a nozzle id of the form HH00-0.4
(high flow) or HS00-0.4 (standard) and can hold both for one diameter -- a
maintainer's H2D carries 102 high-flow entries against 6 standard --
because the same filament reads a different K through each. Nothing read
that, so a standard-flow profile could be selected for a high-flow nozzle
and vice versa.

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

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

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

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

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

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

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

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

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

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

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

The Configure AMS Slot dialog opens on the spool's own configured values,
falling back to the slot's last manual configuration and then the tray's
RFID data. The spool form is wider for the two-pane layout, colour, weight,
cost and location move to their own tab in two columns, and a printer card
in expanded view lists every fitted nozzle size rather than the first entry
alone.
2026-08-27 13:03:15 +02:00
maziggy 59d2713acf Give a no-3MF archive the timelapse baseline it never took (issue #2957)
_capture_timelapse_baseline_at_start says in its own docstring that it must
be called from every on_print_start path that proceeds to a real print, and
what breaks otherwise: the completion scan falls back to snapshotting the
card after the printer has written the video, so the new file lands inside
the baseline and no diff can ever match. There are three such paths. The
fallback branch was not calling it, and nothing else covered the gap --
on_print_running_observed is restart-recovery only and is suppressed
whenever on_print_start fires. Every no-3MF archive therefore reached
completion with no baseline in memory and none on the row, and kept its
timelapse only by accident.

Not confined to the reported cool-off. The same branch serves the
internal-storage verdict, so every H2C/H2D/P2S print that Bambu Studio's
Print button sends to eMMC lost its timelapse the same way, off a card that
was holding it the whole time. Measured on a live install: 0 of 9 fallback
archives had a baseline, against 73 of 275 normal ones.

The branch now takes one like the other two, and last like they are: it
lists the printer's timelapse directory, so a slow card must not delay the
active-print registration, the energy reading, the archive-created event or
the start notification ahead of it.

The other half is a print shorter than the five-minute cool-off, where the
card is still unreadable at the one moment a baseline has to be taken.
list_files_async answers [] when its connect fails rather than raising, so
that is indistinguishable from a card holding no videos. When the cool-off
expired inside the 900-second poll window every video on the card read as
new, the first in listing order won, and a stale unclaimed video was
attached to the print and then deleted off the printer.

The empty baseline is still recorded rather than refused. Bambuddy deletes
each video once it is attached, so the usual card holds exactly one at
completion and an empty baseline resolves it correctly; refusing outright
would lose that common case to protect a rare one, and persisting NULL
instead would send completion to snapshot a card that by then has this
print's video on it. Instead the scan marks such a baseline untrusted and
the attach step declines to choose between several candidates, leaving them
for the manual Scan for Timelapse button. require_unambiguous defaults to
off, so the only caller whose behaviour changes is that scan.
2026-08-27 08:37:35 +02:00
maziggy 9500c046c0 Ask which nozzle to feed when a Filament Track Switch is fitted
Load and Unload in the AMS slot menu did nothing on an H2C with the switch
fitted. The ams_change_filament command carries an optional extruder_id and
Bambuddy never sent it. That is correct on every printer without the switch,
and is what BambuStudio does there too -- each AMS is wired to one hotend, so
the firmware works the target out for itself and an explicit value would only
be a guess at something it already knows. Fit the switch and every AMS is
bound to one of its two inlets instead, either hotend is reachable from any
slot, and a command naming neither leaves the firmware nothing to act on. It
was discarded in silence.

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

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

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

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

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

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

    Also: the response reports per plate whether anything is left to clone, so
    the card explains a stranded plate rather than offering a button that can
    only fail; dispatch skips a stranded plate instead of aborting the whole
    order; and Cancel is offered for any active order.
2026-08-26 10:23:18 +02:00