100 Commits
Author SHA1 Message Date
maziggy 008a74c5b9 Updated CHANGELOG 2026-08-30 08:57:06 +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 f918b4b1f3 Merge remote-tracking branch 'origin/main' into 1.2.5.4 2026-08-29 15:17:59 +02:00
maziggy c5f552921e Updated CHANGELOG 2026-08-29 15:12:27 +02:00
maziggy 909b9cb135 Updated CHANGELOG 2026-08-29 14:51:51 +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 2b3fe10a64 Let sub-project groups be collapsed on the Projects page (issue #2991)
A project with sub-projects drew every one of them expanded, at every
    level, with nothing to shut. Over a three-level hierarchy and a couple of
    hundred archives that makes the page one long scroll.

    Each group's caption is now a chevron that folds that group, and a
    Collapse pill next to the status filter tabs sets the default for the
    page and is remembered across reloads. A shut group takes one grid cell
    rather than a full-width row, so folding a deep tree actually gets the
    page back.

    The count on the caption is of the cards nested there, not the parent
    card's badge: the API counts sub-projects across every status on purpose,
    so under the Active filter the badge can legitimately say 2 where one
    card unfolds. A count that disagrees with what unfolds is worse than no
    count at all.
2026-08-29 14:20:42 +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 597b2b44f1 Updated BACKERS 2026-08-29 14:17:49 +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 a70047c75d Let sub-project groups be collapsed on the Projects page (issue #2991)
A project with sub-projects drew every one of them expanded, at every
level, with nothing to shut. Over a three-level hierarchy and a couple of
hundred archives that makes the page one long scroll.

Each group's caption is now a chevron that folds that group, and a
Collapse pill next to the status filter tabs sets the default for the
page and is remembered across reloads. A shut group takes one grid cell
rather than a full-width row, so folding a deep tree actually gets the
page back.

The count on the caption is of the cards nested there, not the parent
card's badge: the API counts sub-projects across every status on purpose,
so under the Active filter the badge can legitimately say 2 where one
card unfolds. A count that disagrees with what unfolds is worse than no
count at all.
2026-08-29 11:19:07 +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
maziggy 27ecdeb7b9 Updated BACKERS 2026-08-28 13:00:14 +02:00
maziggy 880c7bfe79 Updated BACKERS 2026-08-28 12:59:41 +02:00
maziggy f1096c13eb Updated CHANGELOG 2026-08-28 12:58:25 +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 b3c67c6943 Keep a lookbehind Safari 16 cannot parse out of the bundle (issue #2971)
An iPhone on iOS 16 loaded nothing at all -- no error, no partial render,
just white, over LAN IP and over an HTTPS domain alike, while the same
install was fine on Android, macOS, Windows and Linux. remark-gfm, added
in v1.2.5 for the folder README panel, reaches
mdast-util-gfm-autolink-literal, whose module body carries a lookbehind
assertion. Safari did not support lookbehind until 16.4.

A regex literal is validated when its module is compiled, not when the
function holding it runs, so this was never going to fail as a broken
README panel. FolderReadmePanel -> FileManagerPage -> App is a plain
static import chain, the regex landed in the entry chunk, and the browser
refused to compile all 10 MB of it. Nothing executed, so nothing
rendered. v1.2.4 is the last release that loads on those iOS versions.

The panel now renders GFM through a locally composed plugin holding four
of remark-gfm's five sub-extensions -- tables, strikethrough, task lists,
footnotes -- and omitting autolink literals, the only one carrying the
lookbehind. Composing rather than configuring is forced by the bug:
importing remark-gfm at all is what breaks the page, so no runtime option
could have reached it.

Parity was measured rather than assumed. Serialized ASTs against real
remark-gfm over a 34-case corpus, position data included, are identical
in 29; the five that differ are exactly the autolink cases, where the
only change is link -> text with table and list structure intact. Across
26 hostile inputs -- NUL bytes, a BOM, an RTL override, a lone surrogate,
combining marks, a 200 KB line, 500 stacked tables, 60-deep nesting,
malformed and ragged tables -- neither implementation throws and none
diverge, and applying the plugin twice is idempotent for both.

The visible cost is that a bare https://example.com or foo@example.com
typed into a folder README no longer links itself; [text](url) and
<https://example.com> are core markdown and still do. The wiki claimed
"links all render" and now says which.

remark-gfm, mdast-util-gfm and micromark-extension-gfm leave the
dependency tree and their eight surviving sub-extensions are declared
directly, at ranges equal to or tighter than the ^2.0.0 those two
packages declared, so the resolution surface did not widen. The bundle is
23 KB smaller.

Vite's build.target governs syntax lowering and esbuild does not rewrite
regular expressions -- measured, a lookbehind builds silently under
safari15, safari16.0 and es2020 alike, which is how this shipped and then
sat unnoticed for two months. So the guard is a real check rather than a
compiler setting: npm run build now ends in check-browser-baseline.mjs,
which scans the emitted bundles for syntax Safari 16.0 cannot parse and
fails with the offending snippet. It is scoped to parse-time failures
only -- a missing runtime API breaks one feature, while one of these
takes down the whole app and has no graceful degradation to fall back on.
Verified firing on the stale bundle before the rebuild, and running
correctly inside the Docker frontend stage where only frontend/ is
copied.

Seven renderer tests pin both halves of the trade: each surviving GFM
feature still renders, and both forms of autolinking stay off on purpose
so a future dependency bump cannot quietly bring the lookbehind back.
2026-08-28 08:08:30 +02:00
maziggy 16e9bfaa97 Updated README 2026-08-27 16:14:15 +02:00
maziggy ea0ceae42d Updated README 2026-08-27 16:13:40 +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 426063e829 Updated CHANGELOG 2026-08-27 13:52:29 +02:00
MartinNYHC 35ee7352d3 Merge pull request #2973 from maziggy/refactor/default-profiles
Configure a spool's filament preset and K profile per nozzle

Adds per-printer-model filament presets and per-hotend K profiles to a
spool, and makes every path that configures an AMS slot respect them.

See the CHANGELOG entry for the user-facing description.
2026-08-27 13:44:05 +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 88152dc0c6 Add Dutch to the interface languages (issue #2891)
The translation was contributed as a file on the issue and needed three
corrections before it could be wired up, all of which the parity gate
found.

The nine stats.timeframe.* entries had their keys translated along with
their values -- 'today' had become 'vandaag'. Code resolves those by the
English key, so the Statistics timeframe selector would have found
nothing and rendered raw key names for every Dutch user. The values are
kept and the keys restored.

The file was translated against an older en.ts and was 84 leaves short:
the Filament Track Switch feed prompts, the AI-detection status strings,
the no-3MF internal-history banner, the batch-order stranded-plate
notices, the Avery starting-position field, and the whole
locationHaSensors section from #2824. Rather than splice those in, nl.ts
is regenerated from the en.ts skeleton with the contributor's strings
carried over by key, so its structure, key order and section comments
match the reference exactly and a later diff against en.ts reads as
content rather than as reordering. The generator fails rather than emit a
key it has no translation for, so nothing fell back to English silently.

229 leaves are identical to English. Each was checked and all are kept:
Dutch takes most technical UI vocabulary verbatim -- printer, filament,
status, nozzle, timelapse, dashboard -- and Dutch slicer users use the
English feature names untranslated, so support, ironing, prime tower and
gap fill stay as they are. The 123 distinct values are enumerated in a
NL_COGNATES list in check-i18n-parity.mjs, the same shape the other
twelve locales use, so the exemption is a listed decision per string
rather than a blanket skip for the locale.

Backend app/i18n still carries English and German only, so push
notification text falls back to English for Dutch. That is true of the
eleven other non-German locales too and is left alone here.

Parity green at 6264 leaves across 14 locales.
2026-08-27 07:59:31 +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 ca0aa9952f Updated CHANGELOG 2026-08-26 10:40:00 +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
maziggy 72b097362f Allow Avery label sheets to start at an unused position (#2879) (#2918) 2026-08-26 10:22:57 +02:00
maziggy eee94ce9c8 File the printer's calibration table under the nozzle it belongs to (issue #2854)
The K value on an AMS slot card went blank after a while and came back after a
    backend restart. It is not the MQTT merge: that preserves a tray's k correctly.
    H2-series trays have no k to preserve. Verified against the H2 wire capture in
    logs/vp_wire -- every tray reports cali_idx and nothing else -- so the number on
    the card is resolved from that index against the printer's calibration table in
    state.kprofiles, and that table was a single global list.

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

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

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

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

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

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

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

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

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

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

    The skip is now a warning naming the grams, and a completed print that failed to
    charge a tray it drew from raises the missing-spool-assignment notification. The
    print-start check cannot cover this and was right to stay quiet: the assignments
    existed when it ran. The two are different statements -- the first says the
    weight may not be tracked, the second says it was not -- so a print warned at
    start will notify twice, which is the right trade. Collected across the print
    rather than fired per slot, and given the caller's session, because this runs
    inside on_print_complete's transaction and opening a second one to read the
    printer's name would deadlock against it on SQLite. This is independent of the
    toggle and catches any other cause of an assignment disappearing mid-print.
2026-08-26 10:21:59 +02:00
maziggy f9372607e8 Price a print from the spool that fed it, not the default rate (issue #2591)
Spoolman holds per-spool pricing, and #261 gave that as the reason for
    integrating with it. Nothing ever read it. A print's cost is set once, at
    archive time, from the built-in Filament catalogue matched on the primary type
    and falling back to a global default rate -- and in Spoolman mode nothing
    revisited that figure afterwards. The per-spool recompute that would have fixed
    it, in usage_tracker.on_print_complete, runs only over rows the built-in
    inventory writes, and Spoolman mode hands the usage tracker spoolman_owns_usage
    at print start so it writes none. The reporter's catalogue was empty, which is
    the ordinary state of one in Spoolman mode, so every print came out at the
    default no matter what the linked spool cost.

    Multi-material was wrong twice over there: the primary type's rate applied to
    the whole print's weight, so a slot of expensive PA was billed at the price of
    the PLA beside it.

    Each slot is now priced from the spool it was actually charged to, at the
    moment of the charge, and the per-slot costs are summed -- which is what fixes
    the multi-material case, rather than a separate change. All three charge paths
    feed it: per-slot, tray-split, and the remain%-delta fallback. The rate is the
    spool's own price when set, else the filament's, over filament.weight. That is
    net grams excluding the core, and the same field the remain-delta path already
    divides by to turn a percentage into weight, so a spool that can be charged by
    percentage can always be priced. The price comes out of the get_spool call the
    colour and material rewrites already pay for, so the tagged path costs no extra
    round trip.

    Grams no spool could price are covered at the global default in one subtraction
    against the archive's own total. A spool with no price, a tray with no Spoolman
    row, and filament the sliced file never attributed are the same case from here,
    and without the top-up a print with one priced slot out of four would report a
    quarter of its cost -- #1344 in the other inventory mode. Only the first run
    writes the archive, matching the built-in writer (#1378); reprint actuals live
    in PrintLogEntry. If no slot could be priced at all, whatever archive.py
    recorded is left alone, so an install with prices in neither place stays where
    it was.

    Applied even when the slot-to-tray mapping was a positional guess, unlike the
    colour and material rewrites beside it. Those overwrite what the slicer
    recorded, which is why a guess must not touch them. The cost has no such
    original -- archive.py's figure is itself derived from a default rate -- and the
    grams have already been deducted from these spools, so the archive should say
    what that deduction was worth.

    Both cost recalculations would have undone it on the next run. /rescan and
    /recalculate-costs rebuild an archive's cost from SpoolUsageHistory and fall
    back to the catalogue or the default when there are no rows, which in Spoolman
    mode is always, so the fallback was not a recalculation but a downgrade. The
    spool-to-slot resolution a price is derived from exists only while a print is
    completing and cannot be rebuilt from the archive row, so both now leave a cost
    alone rather than replacing it with a worse one, and the bulk endpoint reports
    how many it kept. An archive with no cost yet is still priced, and with
    Spoolman off both behave exactly as before.

    The rate parser refuses more than it looks like it needs to, because everything
    it refuses was reachable. A non-dict filament raised through a call that sits
    after a successful use_spool, which would have abandoned the remaining slots of
    a multi-material print with the charges already made. NaN compares False
    against every bound, including the applier's own total <= 0, so a NaN price
    would have been written to the archive with nothing downstream able to clear
    it; two finite operands can produce it by overflow, so the quotient is checked
    as well as the inputs. A bool is an int in Python, and float(True) is 1.0 -- a
    weight of 1 g prices a spool per-gram at its whole cost. And a spool-level price
    of 0 now falls through to the catalogue rather than reading as free: Spoolman
    leaves the override null when unset, but importers write 0 often enough that
    treating it literally would price a whole print at the default with a good
    catalogue price one level down.
2026-08-26 10:21:23 +02:00
maziggy 3bc96254ef Charge the tray the printer said it used, not the first one loaded (issue #2953)
A sliced file numbers its filaments 1..4; which AMS tray each came from is
    decided when the job is sent. #2768 gave the Spoolman writer two ways to
    recover that decision when the print did not come through Bambuddy: the
    printer's own mapping field, and a colour match of the 3MF's slots against the
    loaded trays. An A1 satisfies neither. It publishes no mapping field, and it
    drops the MQTT connection when we subscribe to its request topic, so the
    slicer's instruction never arrives either. That leaves the colour match, and it
    compares hex strings exactly.

    The reporter sliced with a generic black profile against a tray they had set to
    charged slot 1 to whatever sat in the first tray -- 2.17 g onto a grey PLA+
    spool, while the print was fed from tray 3. Their bundle carries the printer's
    own answer: "Tray change during print: tray=3 at layer=0", recorded 90 seconds
    in, and read further down the same completion pass by _print_used_tray_keys to
    decide which slots the print had touched. The same pass then charged tray 0 on
    a guess, and logged "AMS0-T3: remain% did not fall over the print" about the
    tray that had actually done the work.

    _single_slot_tray_from_state adds the third rung. For a print with exactly one
    slot carrying usage, the one slot came from the one tray, so the printer's tray
    reporting answers the question directly: the mid-print tray-change log, then
    the tray loaded at print start, then the current one, then the last real tray
    seen. That is the ladder usage_tracker.on_print_complete has consulted since it
    started resolving mappings at completion -- Spoolman users were the only ones
    not getting it, which is why an install running the built-in inventory has
    never shown this. On this printer only the first and last rungs can fire:
    tray_now_at_start is 255 because print start runs before the filament is
    loaded, and the A1 parks tray_now back at 255 the moment a print ends.

    Gated on exactly one slot with usage, like the internal writer: a multi-colour
    print moves tray_now on every change, so one reading cannot then be attributed
    to one slot. It also declines when the log holds more than one switch, because
    an AMS-backup runout is split per segment (#1793) and a single-tray mapping
    would land the whole print on one spool.

    That gate needed the guess warning to exclude the split path too. The split
    never reads slot_to_tray at all -- it charges each segment to the tray the
    printer announced switching to, which is the same evidence this fallback is
    built on -- so a declined mapping there is not a guess, and calling it one
    suppressed the archive rewrite for exactly the prints whose attribution is best
    supported. Nothing covered that combination; a test does now.

    Where nothing names a tray the positional default still stands, because it is
    right for an AMS loaded in slicer order. It now says so at warning level so a
    support bundle carries the reason, and it no longer restamps the archive's
    filament colour and material from a spool it picked by position. That restamp
    is what made the fault read as data loss: the grams can be put back, whereas
    overwriting what the slicer recorded leaves nothing to compare against, and the
    reporter's archive had already been rewritten from #000000 to the wrong spool's
    grey. A slot that consumed nothing also stops claiming a tray in the handled
    set -- it was never charged, so the remain-delta path should stay free to cover
    it rather than be suppressed by an estimate of zero.

    The request-topic probe is the same failure reached from the other side. A
    printer that refuses kills the TCP connection instead of returning a SUBACK
    failure, so the only signal is "we subscribed, then got disconnected", and that
    was believed the first time it happened. Every other reason a connection drops
    inside the same window looks identical -- a network blip, the printer
    rebooting, the container stopped mid-probe -- and the verdict was cached per
    serial with no re-probe anywhere, so on a printer that supports the topic one
    unlucky drop cost mapping capture for the rest of the process and every slicer
    print after it was charged by tray position. It now takes two consecutive
    drops, and a disconnect we asked for is not counted. A printer that genuinely
    refuses answers the same way every time and pays one extra reconnect; one
    already known to refuse still skips the subscription outright rather than
    reopening a reconnect loop.
2026-08-26 10:20:45 +02:00
maziggy 3b29543f8d Give the AMS temperature alarm its own threshold (issue #2905) (#2943) 2026-08-26 10:20:20 +02:00
maziggy 93585beb7b Let a clear spool stay clear on the way to Spoolman (issue #2912) (#2924) 2026-08-26 10:19:50 +02:00
maziggy 8f182dc181 Read a NULL notification flag as off instead of dropping every provider (issue #2827)
Adding on_stock_reorder_alert and on_stock_break_alert to the provider
    schema made them required on the way out as well as in: the response model
    inherits the write model. Every on_* column on notification_providers is
    nullable with no server default, and where the table was created from
    Base.metadata before run_migrations, the ALTER ... DEFAULT false that
    introduced those columns was swallowed as a duplicate and never backfilled
    existing rows. Those NULLs were harmless until the flags were read, at
    which point the row failed validation -- and a list is validated as a
    whole, so one row took every provider with it. The route returned 500 and
    the UI rendered an empty list, so configured providers looked deleted.

    Backfill them to off, which is what the sender already assumed: it selects
    providers with IS TRUE, so a NULL flag never sent anything. A NULL flag now
    also reads as off rather than failing the response, across all of them, so
    the next flag added to this schema cannot repeat it. Writes are unchanged.
2026-08-26 10:19:17 +02:00
maziggy 920c6d1549 Read a print's destination from the report topic, not just the request one (issue #1820)
current_project_url was assigned in exactly one place, _handle_request_message,
    and _on_message calls that only for the request topic. A print started from the
    printer's own screen publishes nothing there, so the field stayed None for the
    one case the storage verdict exists for: the file is already in the printer's
    model library under /userdata/model/history/, which port 990 does not serve.

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

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

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

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

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

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

    -----

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    Test Connection saves the form before probing, so a green result describes the
    configuration the loop actually runs with rather than what is typed in the
    boxes.
2026-08-26 10:17:21 +02:00
maziggy bb82fbc337 Decide migration idempotency by SQLSTATE, not by English error text (issue #2949)
PostgreSQL renders its messages in the server's lc_messages locale. _safe_execute
    recognised an already-applied statement by searching the error text for
    "already exists", so a Russian-locale server -- which says "уже существует" --
    re-raised it and aborted startup.

    The column already existing is the expected outcome: create_all() builds the
    tables from the models before the migration list runs, so on a fresh database
    essentially every ADD COLUMN in that list is a duplicate by design, and all 382
    of them relied on that recognition. No PostgreSQL server outside an English
    locale could start Bambuddy at all, fresh install or upgrade.

    Classify on SQLSTATE instead -- 42701, 42P07, 42710, 23505 -- which PostgreSQL
    never translates. The existing narrowing is kept and now rests on a code rather
    than a phrase: a missing column counts as already-applied only for RENAME COLUMN,
    so a missing column during ADD COLUMN or CREATE INDEX still aborts rather than
    hiding a corrupt schema. SQLite keeps the text match; its driver publishes no
    SQLSTATE and it does not localise. The OIDC auto-link constraint read message
    text the same way and gets the same treatment.

    Verified against PostgreSQL 15 under ru_RU, en_US and C: init_db() completes on
    a fresh database and on a re-run in all three, and the schema the Russian server
    ends up with is byte-identical to the English one.
2026-08-26 10:17:03 +02:00
maziggy dd343e0506 Anchor a plug-energy test to local midnight, not the wall clock (issue #2938)
test_nothing_derivable_before_the_first_midnight failed for 31 minutes of
    every day and passed for the other 23.5 hours -- the shape that reads as
    ordinary flakiness and gets re-run rather than fixed. @ojimpo hit it running
    the full suite at 22:10 UTC, stashed his branch to confirm it reproduced on
    clean dev, and measured the window minute by minute instead of guessing.

    The test asserts that nothing can be derived when the only snapshot was
    taken after this local midnight, and it placed that snapshot at a raw
    wall-clock offset -- now minus thirty minutes. Its comment, "taken this
    morning, after midnight", is the premise, and it is only true away from
    the boundary. For the first half hour of each local day, now minus thirty
    minutes lands before local midnight, where it is a perfectly good baseline:
    _counter_at finds it and today comes back 1.5 rather than None.

    The window is local 00:00 to 00:30, which is 22:00 to 22:30 UTC under CEST
    and 23:00 to 23:30 under CET -- it moves with DST, since the module pins
    Europe/Berlin in an autouse fixture and an outer TZ makes no difference.

    Nothing is wrong with the production code. A snapshot from before local
    midnight genuinely is a valid baseline for today, and derive_today_yesterday
    is right to treat it as one. Only the test's premise breaks at the boundary.

    The snapshot is now anchored to local_day_start(now) plus thirty minutes,
    which is the idiom the other nine snapshot writes in this file already use
    and the reason none of them can drift. Replayed across 5760 minutes covering
    four days, including both DST switch days: the old expression fails 31
    minutes per day, the new one fails none.
2026-08-26 10:16:48 +02:00
maziggy 2b72e41027 Updated CHANGELOG 2026-08-26 10:16:32 +02:00
maziggy 5be2151f99 Take an RFID spool's core weight from the row that names it (issue #2909) (#2923) 2026-08-26 10:16:15 +02:00
maziggy 32c945db39 Let one checkbox say where a slice's settings come from (issue #2942)
Two features in the slice dialog read as one. "Use the file's built-in
    settings" slices a 3MF the way its designer set it up, ignoring the picked
    profiles. The per-option "from file" ticks beside each setting carry the
    designer's individual deviations onto the profile you picked, and those
    arrived pre-ticked whatever the checkbox said. So a slice run deliberately
    without the file's settings still took sixteen values out of it -- the
    reporter's log names them, enable_support and support_type among them,
    landing on a process preset they had chosen on purpose.

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

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

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

    Worth knowing: a Bambu Studio file with supports enabled no longer switches
    supports on for you. Tick Enable support, or the checkbox above the panel.
    Measured against the reporter's own sixteen keys, and covered by backend
    and frontend tests -- reverting any one of the three changes fails tests.
2026-08-26 10:15:48 +02:00
maziggy 818543c4f6 Say which blue a colour mismatch is about (issue #2941)
A print was refused a colour match between two filaments the dialog itself
    labelled "Blue". The comparison was right: the slicer profile asked for a
    near-pure #0028FF and the slot held Bambu's navy #0A2989, 118 apart in the
    blue channel alone and a CIEDE2000 distance of 15, where 1 is a
    just-noticeable difference. Nothing on screen said so. A hex that misses the
    colour catalogue is named by a coarse family bucket, so both sides resolved
    to the same word, and the warning sat between two identical labels with
    nothing to reconcile it against. Read as a broken matcher, which is what the
    report said, and a fair reading of what was shown.

    Where both sides of a mismatch carry the same name they are now qualified by
    their hex, and the tooltip names them together: "Same type, different color:
    needs Blue (#0028FF), slot has Blue (#0A2989)". Names that already differ are
    left alone -- the hex is noise once the words separate them -- and a side with
    no name falls back to its hex rather than growing an empty bracket.

    The comparison is untouched. It was correct, and its tolerance is not
    something to widen on one report: a difference that size would start matching
    navy to cyan, and eligibility is the same rule the queue scheduler dispatches
    on, so loosening it would change which spool gets printed rather than only
    which warning gets shown. This issue was about being able to see why the
    warning fired, and a test pins that it still fires.

    The strings around it were hardcoded English: the panel's status line, the
    required-filament tooltip, the auto-matched marker, the slot placeholder, the
    mismatch detail and the type-not-found message. All nine now go through
    translation, in all thirteen locales. Two of them -- "Same type, different
    color" and "Filament type not loaded" -- turned out to have had translations
    sitting in every locale file the whole time, unused, while the component
    rendered the English literal beside them. The parity gate could not have
    caught any of this: keys that were never added have nothing to compare
    against.
2026-08-26 10:15:30 +02:00
maziggy b212ba984a Carry a fault's description in the status response (issue #2926)
The HMS catalogue has been in the backend all along and the status
    response never carried it, so every consumer that wanted to tell a user
    why a print halted resolved the same 853 codes from its own duplicate of
    the same sentences -- this repo's Python table, the frontend modal's, and
    at least one third-party client whose catalogue exists purely because the
    server would not say. Each ages separately, and a relay watching a
    printer could only manage "your printer needs attention" while the server
    already knew it was "Filament ran out. Please load new filament."

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

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

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

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

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

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

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

    -----

    Record a failure code the user can actually look up

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

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

    There is already a helper that gets this right, used by the archive's own
    failure-reason lookup, so this calls it instead of keeping a fourth copy
    of the derivation. It also takes the raw integer code the MQTT payload
    carries, which the local version only handled as a string.
2026-08-26 10:14:55 +02:00
maziggy 3da0c18c4d Let a virtual printer be told which address to advertise
BambuStudio reads its FTP upload destination out of net.info[].ip in the
    MQTT status, and the bridge fills that field from the VP's bind address.
    On Docker bridge networking the two are different machines' worth of
    address: slicers reach Bambuddy on the host's LAN IP, the container binds
    something like 172.24.0.2, and that private address is what the slicer was
    handed -- so it opened an FTP connection to an address that does not exist
    on its network and the send stalled around 10%.

    VIRTUAL_PRINTER_ADVERTISE_ADDRESS supplies the address slicers actually
    use, taking precedence over the bind address and over the same-subnet host
    interface the bridge falls back to. The armed log line names its source, so
    (VIRTUAL_PRINTER_ADVERTISE_ADDRESS) against (bind_address) tells an
    operator whether the variable reached the container at all, and the
    not-armed diagnostic now names it as the remedy -- a bridge-network install
    that has not set it is exactly the one that cannot auto-resolve either, and
    until now saw only that nothing worked.

    An environment variable rather than a change to how the advertised address
    is resolved, which is the decision worth recording. The VP already has a
    "Network Interface Override" field, and reading it here is the smaller
    patch, but it feeds SSDP and the certificate SANs only: honouring it would
    silently move the upload destination on every install that has one set --
    the multi-NIC, VLAN and Tailscale setups, which are the ones most likely to
    have been arrived at by hand and least likely to survive being
    second-guessed. Unset, this changes nothing, and a test pins that.

    A value that is not a dotted-quad IPv4 is refused with one warning naming
    it and the previous address is used instead. That direction is deliberate:
    declining to rewrite would put the real printer's IP back in front of the
    slicer, which is the leak the rewrite exists to close, so a typo must not
    be able to reopen it. 0.0.0.0 counts as unset and whitespace is stripped,
    for values pasted into a compose file.

    Host and macvlan networking need none of this and stay what Virtual Printer
    is developed against. The variable removes one blocker; it does not make
    bridge mode equivalent. The wiki said in three places that the host address
    could not be discovered at all, which is no longer true, so those now
    describe the variable and keep the recommendation.
2026-08-26 10:14:27 +02:00
maziggy 5de06cca0f Stop offering AMS slots as places to store a spool
The Storage Location dropdown listed entries like "H2D-1 - AMS A1" next to
        real locations, and they could not be got rid of.

        They were never locations. Bambuddy used to record which slot a spool was
        loaded into by writing that string into Spoolman's location field, and the
        writer went away when Storage Location became something the user picks --
        but the strings stayed on people's Spoolman spools, and the location sync
        imports every distinct one it finds, so they have been coming back in
        through the front door ever since. A printer slot is where a spool is
        loaded, not where it is put away, and slot assignments already track the
        first.

        Deleting one by hand did not work either, which is what made this a dead
        end rather than an annoyance: the delete route refuses a location that has
        spools, and in Spoolman mode it counts them by matching that same string,
        so every marker still sitting on a loaded spool answered 409 -- and the two
        that were empty were back on the next sync a minute later.

        The import now skips them and a one-shot migration clears the ones already
        in the catalogue. The shape is defined once and used by both: an optional
        printer-name prefix followed by AMS A1, AMS-HT A1 or External Spool, which
        is exactly what convert_ams_slot_to_location produced. It stays narrow on
        purpose -- "AMS Drybox" and "Spare AMS trays" are somebody's shelf, and
        anything the filter swallowed would be a place they could no longer file a
        spool under -- so both directions are pinned by tests.

        A row is only removed when no spool in this database points at it, by id or
        by legacy free-text name, so an internal-mode user who has deliberately
        filed spools under such a name keeps it. Spools in Spoolman are neither
        consulted nor touched: their location strings are the user's data on the
        user's server, and one that still reads "H2D-1 - AMS A1" in the inventory
        list is telling the truth about what Spoolman holds. It simply stops being
        offered as a destination.

        Verified on a live Postgres instance carrying the reported symptom: 13
        locations down to 3, all ten markers removed, the two real shelves and one
        hand-typed Spoolman name left alone.
2026-08-26 10:14:09 +02:00
maziggy 8c01e0f8ea Draw a spool the way the AMS described it, and correct the tare it was added with
Two faults in the same auto-add path, both found while tracing why an H2C
        slot named a wood roll as plain PLA.

        A spool's swatch is composed from effect_type and extra_colors, and the
        RFID auto-add set neither. It reads the colour catalogue to name the
        colour and took the name alone, even though the row it had in hand also
        carries those two columns -- the spool form's own colour picker hands both
        to a spool a user adds by hand, so the same roll rendered one way when you
        typed it in and another when the printer identified it for you.

        Both columns now travel with the name. That alone changes nothing on a
        stock install, because the shipped catalogue carries an effect on none of
        its 600-odd rows, so the subtype is read where the catalogue has none: it
        is already derived from what the printer reports, and the two vocabularies
        line up -- Wood, Silk, Sparkle, Marble, Glow, Galaxy, Metal, Rainbow,
        Translucent, Matte, and the Gradient, Dual Color and Tri Color that the
        M*/T* colour codes upgrade a subtype to. "Silk+" reads as Silk, since the
        plus is on the product name rather than the finish. A subtype that names
        no effect -- Basic, Tough, CF -- leaves the column empty rather than
        inventing an overlay, and a value already set is never overwritten, so the
        column stays what it is documented to be: a rendering hint the user can
        override without touching Bambu's categorical label.

        ------

        Correct the spool tare an RFID roll was added with (#2909)

        The lookup that gave an auto-added spool its core_weight asked for the
        first catalogue row whose name starts "Bambu Lab" and took whatever came
        back. There are three, and which is first is the database's business:
        SQLite returns insertion order in practice, Postgres promises nothing once
        a table has seen an update. The same roll was therefore recorded with the
        216 g High Temp tare on one install and correctly with the 250 g Low Temp
        one on another. @ojimpo's forward fix picks the row by name; this repairs
        the rows already written, which the forward fix cannot reach -- 22 of 26
        RFID-added spools on the instance this was traced on.

        The tare is not cosmetic. A spool weighed on SpoolBuddy has its remaining
        filament worked out as the scale reading minus the tare, so a 34 g low
        tare credits the roll with 34 g that is not there and writes a used weight
        34 g short. That error is a constant -- every later print adds to the used
        weight on top of it -- so adding the difference back is exact however much
        has been printed since. It is applied only to spools that have been on the
        scale; one that never was has a used weight derived from the AMS remaining
        percentage, which the tare never entered into.

        Rows are identified by the signature of the broken lookup: added by RFID,
        carrying the weight of one of the other Bambu catalogue rows, with the
        weights read out of the catalogue rather than hardcoded so an install
        whose rows have been re-measured is repaired to its own numbers. Keying on
        whether a catalogue row had been recorded would not have worked -- the
        weight picker auto-selects the only row matching the weight and writes its
        id on the next save, so that column says only whether the form was ever
        opened. The one case that cannot be told apart is stated rather than
        hidden: someone who moved an RFID roll onto a genuine High Temp spool and
        set 216 g by hand is normalised with the rest. Runs exactly once, so a
        tare set afterwards is kept.
2026-08-26 10:13:42 +02:00
maziggy 09d8ca351d Name a spool by its subtype on the slot it is assigned to
A spool's subtype is half of what it is called: "PLA" and "PLA Wood" are
        different filaments. The AMS slot's hover card built the assigned-spool
        line out of brand, material and colour name and left the subtype out, so
        a roll of Bambu PLA Wood Classic Birch in an H2C's A4 was announced as
        "Bambu Lab PLA - Classic Birch".

        Everything else named it correctly at the same moment -- the RFID read,
        the inventory row, the slot's own profile line, which is built from the
        spool's slicer preset rather than reassembled, and Bambu Studio -- so the
        one wrong line read like a bad tag read rather than a display fault.

        It was not only the render. The card's assignedSpool prop had no subtype
        field at all, and the six places the printer card fills it in -- regular
        AMS, AMS-HT and external spool, each in both Spoolman and internal-
        inventory mode -- never passed one, so the value could not reach the
        component. The field is required rather than optional, which is what
        stops the next call site from quietly omitting it; that omission is the
        whole of this bug.

        Three more surfaces rebuilt the name the same way and are fixed with it:
        the SpoolBuddy AMS slot panel in both inventory modes, and the write-tag
        confirmation. Every other place a spool is named -- the assign dialogs,
        the inventory cards, the forecast rows, the label picker -- already
        included the subtype, so these four were the outliers.

        This is the display-side half of #2902, which stopped the backend
        reducing a filled or foamed filament onto its base material. The card was
        doing the same thing to the same spools, one layer further out.
2026-08-26 10:13:19 +02:00
maziggy 196fcaf15b Keep the last run a batch order can re-queue a plate from
An order produces what it still owes by cloning an existing queue item for
the same plate. That row is the only record of the printer target, AMS
mapping and print options the user chose, so deleting the last one left the
order reporting work outstanding that nothing could produce, and no way to
close it out: Cancel was hidden unless there were pending items to cancel,
which by then there were none.

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

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

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

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

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

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

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

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

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

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

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

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

The skip is now a warning naming the grams, and a completed print that failed to
charge a tray it drew from raises the missing-spool-assignment notification. The
print-start check cannot cover this and was right to stay quiet: the assignments
existed when it ran. The two are different statements -- the first says the
weight may not be tracked, the second says it was not -- so a print warned at
start will notify twice, which is the right trade. Collected across the print
rather than fired per slot, and given the caller's session, because this runs
inside on_print_complete's transaction and opening a second one to read the
printer's name would deadlock against it on SQLite. This is independent of the
toggle and catches any other cause of an assignment disappearing mid-print.
2026-08-25 17:17:26 +02:00
maziggy 39835437a3 Price a print from the spool that fed it, not the default rate (issue #2591)
Spoolman holds per-spool pricing, and #261 gave that as the reason for
integrating with it. Nothing ever read it. A print's cost is set once, at
archive time, from the built-in Filament catalogue matched on the primary type
and falling back to a global default rate -- and in Spoolman mode nothing
revisited that figure afterwards. The per-spool recompute that would have fixed
it, in usage_tracker.on_print_complete, runs only over rows the built-in
inventory writes, and Spoolman mode hands the usage tracker spoolman_owns_usage
at print start so it writes none. The reporter's catalogue was empty, which is
the ordinary state of one in Spoolman mode, so every print came out at the
default no matter what the linked spool cost.

Multi-material was wrong twice over there: the primary type's rate applied to
the whole print's weight, so a slot of expensive PA was billed at the price of
the PLA beside it.

Each slot is now priced from the spool it was actually charged to, at the
moment of the charge, and the per-slot costs are summed -- which is what fixes
the multi-material case, rather than a separate change. All three charge paths
feed it: per-slot, tray-split, and the remain%-delta fallback. The rate is the
spool's own price when set, else the filament's, over filament.weight. That is
net grams excluding the core, and the same field the remain-delta path already
divides by to turn a percentage into weight, so a spool that can be charged by
percentage can always be priced. The price comes out of the get_spool call the
colour and material rewrites already pay for, so the tagged path costs no extra
round trip.

Grams no spool could price are covered at the global default in one subtraction
against the archive's own total. A spool with no price, a tray with no Spoolman
row, and filament the sliced file never attributed are the same case from here,
and without the top-up a print with one priced slot out of four would report a
quarter of its cost -- #1344 in the other inventory mode. Only the first run
writes the archive, matching the built-in writer (#1378); reprint actuals live
in PrintLogEntry. If no slot could be priced at all, whatever archive.py
recorded is left alone, so an install with prices in neither place stays where
it was.

Applied even when the slot-to-tray mapping was a positional guess, unlike the
colour and material rewrites beside it. Those overwrite what the slicer
recorded, which is why a guess must not touch them. The cost has no such
original -- archive.py's figure is itself derived from a default rate -- and the
grams have already been deducted from these spools, so the archive should say
what that deduction was worth.

Both cost recalculations would have undone it on the next run. /rescan and
/recalculate-costs rebuild an archive's cost from SpoolUsageHistory and fall
back to the catalogue or the default when there are no rows, which in Spoolman
mode is always, so the fallback was not a recalculation but a downgrade. The
spool-to-slot resolution a price is derived from exists only while a print is
completing and cannot be rebuilt from the archive row, so both now leave a cost
alone rather than replacing it with a worse one, and the bulk endpoint reports
how many it kept. An archive with no cost yet is still priced, and with
Spoolman off both behave exactly as before.

The rate parser refuses more than it looks like it needs to, because everything
it refuses was reachable. A non-dict filament raised through a call that sits
after a successful use_spool, which would have abandoned the remaining slots of
a multi-material print with the charges already made. NaN compares False
against every bound, including the applier's own total <= 0, so a NaN price
would have been written to the archive with nothing downstream able to clear
it; two finite operands can produce it by overflow, so the quotient is checked
as well as the inputs. A bool is an int in Python, and float(True) is 1.0 -- a
weight of 1 g prices a spool per-gram at its whole cost. And a spool-level price
of 0 now falls through to the catalogue rather than reading as free: Spoolman
leaves the override null when unset, but importers write 0 often enough that
treating it literally would price a whole print at the default with a good
catalogue price one level down.
2026-08-25 16:45:50 +02:00
maziggy 935d4b5bbf Charge the tray the printer said it used, not the first one loaded (issue #2953)
A sliced file numbers its filaments 1..4; which AMS tray each came from is
decided when the job is sent. #2768 gave the Spoolman writer two ways to
recover that decision when the print did not come through Bambuddy: the
printer's own mapping field, and a colour match of the 3MF's slots against the
loaded trays. An A1 satisfies neither. It publishes no mapping field, and it
drops the MQTT connection when we subscribe to its request topic, so the
slicer's instruction never arrives either. That leaves the colour match, and it
compares hex strings exactly.

The reporter sliced with a generic black profile against a tray they had set to
charged slot 1 to whatever sat in the first tray -- 2.17 g onto a grey PLA+
spool, while the print was fed from tray 3. Their bundle carries the printer's
own answer: "Tray change during print: tray=3 at layer=0", recorded 90 seconds
in, and read further down the same completion pass by _print_used_tray_keys to
decide which slots the print had touched. The same pass then charged tray 0 on
a guess, and logged "AMS0-T3: remain% did not fall over the print" about the
tray that had actually done the work.

_single_slot_tray_from_state adds the third rung. For a print with exactly one
slot carrying usage, the one slot came from the one tray, so the printer's tray
reporting answers the question directly: the mid-print tray-change log, then
the tray loaded at print start, then the current one, then the last real tray
seen. That is the ladder usage_tracker.on_print_complete has consulted since it
started resolving mappings at completion -- Spoolman users were the only ones
not getting it, which is why an install running the built-in inventory has
never shown this. On this printer only the first and last rungs can fire:
tray_now_at_start is 255 because print start runs before the filament is
loaded, and the A1 parks tray_now back at 255 the moment a print ends.

Gated on exactly one slot with usage, like the internal writer: a multi-colour
print moves tray_now on every change, so one reading cannot then be attributed
to one slot. It also declines when the log holds more than one switch, because
an AMS-backup runout is split per segment (#1793) and a single-tray mapping
would land the whole print on one spool.

That gate needed the guess warning to exclude the split path too. The split
never reads slot_to_tray at all -- it charges each segment to the tray the
printer announced switching to, which is the same evidence this fallback is
built on -- so a declined mapping there is not a guess, and calling it one
suppressed the archive rewrite for exactly the prints whose attribution is best
supported. Nothing covered that combination; a test does now.

Where nothing names a tray the positional default still stands, because it is
right for an AMS loaded in slicer order. It now says so at warning level so a
support bundle carries the reason, and it no longer restamps the archive's
filament colour and material from a spool it picked by position. That restamp
is what made the fault read as data loss: the grams can be put back, whereas
overwriting what the slicer recorded leaves nothing to compare against, and the
reporter's archive had already been rewritten from #000000 to the wrong spool's
grey. A slot that consumed nothing also stops claiming a tray in the handled
set -- it was never charged, so the remain-delta path should stay free to cover
it rather than be suppressed by an estimate of zero.

The request-topic probe is the same failure reached from the other side. A
printer that refuses kills the TCP connection instead of returning a SUBACK
failure, so the only signal is "we subscribed, then got disconnected", and that
was believed the first time it happened. Every other reason a connection drops
inside the same window looks identical -- a network blip, the printer
rebooting, the container stopped mid-probe -- and the verdict was cached per
serial with no re-probe anywhere, so on a printer that supports the topic one
unlucky drop cost mapping capture for the rest of the process and every slicer
print after it was charged by tray position. It now takes two consecutive
drops, and a disconnect we asked for is not counted. A printer that genuinely
refuses answers the same way every time and pays one extra reconnect; one
already known to refuse still skips the subscription outright rather than
reopening a reconnect loop.
2026-08-25 16:22:43 +02:00
maziggy 4dc1aa7b10 Updated CHANGELOG 2026-08-25 15:05:21 +02:00
maziggy 8fe536fb38 Updated CHANGELOG 2026-08-25 13:50:29 +02:00
maziggy d9bc7ae47a Read a NULL notification flag as off instead of dropping every provider (issue #2827)
Adding on_stock_reorder_alert and on_stock_break_alert to the provider
schema made them required on the way out as well as in: the response model
inherits the write model. Every on_* column on notification_providers is
nullable with no server default, and where the table was created from
Base.metadata before run_migrations, the ALTER ... DEFAULT false that
introduced those columns was swallowed as a duplicate and never backfilled
existing rows. Those NULLs were harmless until the flags were read, at
which point the row failed validation -- and a list is validated as a
whole, so one row took every provider with it. The route returned 500 and
the UI rendered an empty list, so configured providers looked deleted.

Backfill them to off, which is what the sender already assumed: it selects
providers with IS TRUE, so a NULL flag never sent anything. A NULL flag now
also reads as off rather than failing the response, across all of them, so
the next flag added to this schema cannot repeat it. Writes are unchanged.
2026-08-25 13:09:24 +02:00
maziggy df57e5213b Updated CHANGELOG 2026-08-25 12:21:19 +02:00
maziggy 5dd7bd213f Read a print's destination from the report topic, not just the request one (issue #1820)
current_project_url was assigned in exactly one place, _handle_request_message,
and _on_message calls that only for the request topic. A print started from the
printer's own screen publishes nothing there, so the field stayed None for the
one case the storage verdict exists for: the file is already in the printer's
model library under /userdata/model/history/, which port 990 does not serve.

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

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

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

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

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

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

-----

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Test Connection saves the form before probing, so a green result describes the
configuration the loop actually runs with rather than what is typed in the
boxes.
2026-08-25 08:49:24 +02:00
maziggy ea898141f0 Decide migration idempotency by SQLSTATE, not by English error text (issue #2949)
PostgreSQL renders its messages in the server's lc_messages locale. _safe_execute
recognised an already-applied statement by searching the error text for
"already exists", so a Russian-locale server -- which says "уже существует" --
re-raised it and aborted startup.

The column already existing is the expected outcome: create_all() builds the
tables from the models before the migration list runs, so on a fresh database
essentially every ADD COLUMN in that list is a duplicate by design, and all 382
of them relied on that recognition. No PostgreSQL server outside an English
locale could start Bambuddy at all, fresh install or upgrade.

Classify on SQLSTATE instead -- 42701, 42P07, 42710, 23505 -- which PostgreSQL
never translates. The existing narrowing is kept and now rests on a code rather
than a phrase: a missing column counts as already-applied only for RENAME COLUMN,
so a missing column during ADD COLUMN or CREATE INDEX still aborts rather than
hiding a corrupt schema. SQLite keeps the text match; its driver publishes no
SQLSTATE and it does not localise. The OIDC auto-link constraint read message
text the same way and gets the same treatment.

Verified against PostgreSQL 15 under ru_RU, en_US and C: init_db() completes on
a fresh database and on a re-run in all three, and the schema the Russian server
ends up with is byte-identical to the English one.
2026-08-25 07:55:49 +02:00
maziggy ed84f0f74c Anchor a plug-energy test to local midnight, not the wall clock (issue #2938)
test_nothing_derivable_before_the_first_midnight failed for 31 minutes of
every day and passed for the other 23.5 hours -- the shape that reads as
ordinary flakiness and gets re-run rather than fixed. @ojimpo hit it running
the full suite at 22:10 UTC, stashed his branch to confirm it reproduced on
clean dev, and measured the window minute by minute instead of guessing.

The test asserts that nothing can be derived when the only snapshot was
taken after this local midnight, and it placed that snapshot at a raw
wall-clock offset -- now minus thirty minutes. Its comment, "taken this
morning, after midnight", is the premise, and it is only true away from
the boundary. For the first half hour of each local day, now minus thirty
minutes lands before local midnight, where it is a perfectly good baseline:
_counter_at finds it and today comes back 1.5 rather than None.

The window is local 00:00 to 00:30, which is 22:00 to 22:30 UTC under CEST
and 23:00 to 23:30 under CET -- it moves with DST, since the module pins
Europe/Berlin in an autouse fixture and an outer TZ makes no difference.

Nothing is wrong with the production code. A snapshot from before local
midnight genuinely is a valid baseline for today, and derive_today_yesterday
is right to treat it as one. Only the test's premise breaks at the boundary.

The snapshot is now anchored to local_day_start(now) plus thirty minutes,
which is the idiom the other nine snapshot writes in this file already use
and the reason none of them can drift. Replayed across 5760 minutes covering
four days, including both DST switch days: the old expression fails 31
minutes per day, the new one fails none.
2026-08-24 13:29:28 +02:00
maziggy e2a2e06da3 Updated CHANGELOG 2026-08-24 11:53:53 +02:00
maziggy b1f5ec9642 Let one checkbox say where a slice's settings come from (issue #2942)
Two features in the slice dialog read as one. "Use the file's built-in
settings" slices a 3MF the way its designer set it up, ignoring the picked
profiles. The per-option "from file" ticks beside each setting carry the
designer's individual deviations onto the profile you picked, and those
arrived pre-ticked whatever the checkbox said. So a slice run deliberately
without the file's settings still took sixteen values out of it -- the
reporter's log names them, enable_support and support_type among them,
landing on a process preset they had chosen on purpose.

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

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

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

Worth knowing: a Bambu Studio file with supports enabled no longer switches
supports on for you. Tick Enable support, or the checkbox above the panel.
Measured against the reporter's own sixteen keys, and covered by backend
and frontend tests -- reverting any one of the three changes fails tests.
2026-08-24 11:04:33 +02:00
maziggy cbbdab86f9 Say which blue a colour mismatch is about (issue #2941)
A print was refused a colour match between two filaments the dialog itself
labelled "Blue". The comparison was right: the slicer profile asked for a
near-pure #0028FF and the slot held Bambu's navy #0A2989, 118 apart in the
blue channel alone and a CIEDE2000 distance of 15, where 1 is a
just-noticeable difference. Nothing on screen said so. A hex that misses the
colour catalogue is named by a coarse family bucket, so both sides resolved
to the same word, and the warning sat between two identical labels with
nothing to reconcile it against. Read as a broken matcher, which is what the
report said, and a fair reading of what was shown.

Where both sides of a mismatch carry the same name they are now qualified by
their hex, and the tooltip names them together: "Same type, different color:
needs Blue (#0028FF), slot has Blue (#0A2989)". Names that already differ are
left alone -- the hex is noise once the words separate them -- and a side with
no name falls back to its hex rather than growing an empty bracket.

The comparison is untouched. It was correct, and its tolerance is not
something to widen on one report: a difference that size would start matching
navy to cyan, and eligibility is the same rule the queue scheduler dispatches
on, so loosening it would change which spool gets printed rather than only
which warning gets shown. This issue was about being able to see why the
warning fired, and a test pins that it still fires.

The strings around it were hardcoded English: the panel's status line, the
required-filament tooltip, the auto-matched marker, the slot placeholder, the
mismatch detail and the type-not-found message. All nine now go through
translation, in all thirteen locales. Two of them -- "Same type, different
color" and "Filament type not loaded" -- turned out to have had translations
sitting in every locale file the whole time, unused, while the component
rendered the English literal beside them. The parity gate could not have
caught any of this: keys that were never added have nothing to compare
against.
2026-08-24 10:14:31 +02:00
maziggy 6988a30eae Carry a fault's description in the status response (issue #2926)
The HMS catalogue has been in the backend all along and the status
response never carried it, so every consumer that wanted to tell a user
why a print halted resolved the same 853 codes from its own duplicate of
the same sentences -- this repo's Python table, the frontend modal's, and
at least one third-party client whose catalogue exists purely because the
server would not say. Each ages separately, and a relay watching a
printer could only manage "your printer needs attention" while the server
already knew it was "Filament ran out. Please load new filament."

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

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

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

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

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

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

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

-----

Record a failure code the user can actually look up

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

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

There is already a helper that gets this right, used by the archive's own
failure-reason lookup, so this calls it instead of keeping a fourth copy
of the derivation. It also takes the raw integer code the MQTT payload
carries, which the local version only handled as a string.
2026-08-24 09:47:39 +02:00
maziggy c094102614 Let a virtual printer be told which address to advertise
BambuStudio reads its FTP upload destination out of net.info[].ip in the
MQTT status, and the bridge fills that field from the VP's bind address.
On Docker bridge networking the two are different machines' worth of
address: slicers reach Bambuddy on the host's LAN IP, the container binds
something like 172.24.0.2, and that private address is what the slicer was
handed -- so it opened an FTP connection to an address that does not exist
on its network and the send stalled around 10%.

VIRTUAL_PRINTER_ADVERTISE_ADDRESS supplies the address slicers actually
use, taking precedence over the bind address and over the same-subnet host
interface the bridge falls back to. The armed log line names its source, so
(VIRTUAL_PRINTER_ADVERTISE_ADDRESS) against (bind_address) tells an
operator whether the variable reached the container at all, and the
not-armed diagnostic now names it as the remedy -- a bridge-network install
that has not set it is exactly the one that cannot auto-resolve either, and
until now saw only that nothing worked.

An environment variable rather than a change to how the advertised address
is resolved, which is the decision worth recording. The VP already has a
"Network Interface Override" field, and reading it here is the smaller
patch, but it feeds SSDP and the certificate SANs only: honouring it would
silently move the upload destination on every install that has one set --
the multi-NIC, VLAN and Tailscale setups, which are the ones most likely to
have been arrived at by hand and least likely to survive being
second-guessed. Unset, this changes nothing, and a test pins that.

A value that is not a dotted-quad IPv4 is refused with one warning naming
it and the previous address is used instead. That direction is deliberate:
declining to rewrite would put the real printer's IP back in front of the
slicer, which is the leak the rewrite exists to close, so a typo must not
be able to reopen it. 0.0.0.0 counts as unset and whitespace is stripped,
for values pasted into a compose file.

Host and macvlan networking need none of this and stay what Virtual Printer
is developed against. The variable removes one blocker; it does not make
bridge mode equivalent. The wiki said in three places that the host address
could not be discovered at all, which is no longer true, so those now
describe the variable and keep the recommendation.
2026-08-24 08:58:41 +02:00
maziggy 537b4d2509 Stop offering AMS slots as places to store a spool
The Storage Location dropdown listed entries like "H2D-1 - AMS A1" next to
    real locations, and they could not be got rid of.

    They were never locations. Bambuddy used to record which slot a spool was
    loaded into by writing that string into Spoolman's location field, and the
    writer went away when Storage Location became something the user picks --
    but the strings stayed on people's Spoolman spools, and the location sync
    imports every distinct one it finds, so they have been coming back in
    through the front door ever since. A printer slot is where a spool is
    loaded, not where it is put away, and slot assignments already track the
    first.

    Deleting one by hand did not work either, which is what made this a dead
    end rather than an annoyance: the delete route refuses a location that has
    spools, and in Spoolman mode it counts them by matching that same string,
    so every marker still sitting on a loaded spool answered 409 -- and the two
    that were empty were back on the next sync a minute later.

    The import now skips them and a one-shot migration clears the ones already
    in the catalogue. The shape is defined once and used by both: an optional
    printer-name prefix followed by AMS A1, AMS-HT A1 or External Spool, which
    is exactly what convert_ams_slot_to_location produced. It stays narrow on
    purpose -- "AMS Drybox" and "Spare AMS trays" are somebody's shelf, and
    anything the filter swallowed would be a place they could no longer file a
    spool under -- so both directions are pinned by tests.

    A row is only removed when no spool in this database points at it, by id or
    by legacy free-text name, so an internal-mode user who has deliberately
    filed spools under such a name keeps it. Spools in Spoolman are neither
    consulted nor touched: their location strings are the user's data on the
    user's server, and one that still reads "H2D-1 - AMS A1" in the inventory
    list is telling the truth about what Spoolman holds. It simply stops being
    offered as a destination.

    Verified on a live Postgres instance carrying the reported symptom: 13
    locations down to 3, all ten markers removed, the two real shelves and one
    hand-typed Spoolman name left alone.
2026-08-23 15:17:09 +02:00
maziggy b38022ec5c Draw a spool the way the AMS described it, and correct the tare it was added with
Two faults in the same auto-add path, both found while tracing why an H2C
    slot named a wood roll as plain PLA.

    A spool's swatch is composed from effect_type and extra_colors, and the
    RFID auto-add set neither. It reads the colour catalogue to name the
    colour and took the name alone, even though the row it had in hand also
    carries those two columns -- the spool form's own colour picker hands both
    to a spool a user adds by hand, so the same roll rendered one way when you
    typed it in and another when the printer identified it for you.

    Both columns now travel with the name. That alone changes nothing on a
    stock install, because the shipped catalogue carries an effect on none of
    its 600-odd rows, so the subtype is read where the catalogue has none: it
    is already derived from what the printer reports, and the two vocabularies
    line up -- Wood, Silk, Sparkle, Marble, Glow, Galaxy, Metal, Rainbow,
    Translucent, Matte, and the Gradient, Dual Color and Tri Color that the
    M*/T* colour codes upgrade a subtype to. "Silk+" reads as Silk, since the
    plus is on the product name rather than the finish. A subtype that names
    no effect -- Basic, Tough, CF -- leaves the column empty rather than
    inventing an overlay, and a value already set is never overwritten, so the
    column stays what it is documented to be: a rendering hint the user can
    override without touching Bambu's categorical label.

    ------

    Correct the spool tare an RFID roll was added with (#2909)

    The lookup that gave an auto-added spool its core_weight asked for the
    first catalogue row whose name starts "Bambu Lab" and took whatever came
    back. There are three, and which is first is the database's business:
    SQLite returns insertion order in practice, Postgres promises nothing once
    a table has seen an update. The same roll was therefore recorded with the
    216 g High Temp tare on one install and correctly with the 250 g Low Temp
    one on another. @ojimpo's forward fix picks the row by name; this repairs
    the rows already written, which the forward fix cannot reach -- 22 of 26
    RFID-added spools on the instance this was traced on.

    The tare is not cosmetic. A spool weighed on SpoolBuddy has its remaining
    filament worked out as the scale reading minus the tare, so a 34 g low
    tare credits the roll with 34 g that is not there and writes a used weight
    34 g short. That error is a constant -- every later print adds to the used
    weight on top of it -- so adding the difference back is exact however much
    has been printed since. It is applied only to spools that have been on the
    scale; one that never was has a used weight derived from the AMS remaining
    percentage, which the tare never entered into.

    Rows are identified by the signature of the broken lookup: added by RFID,
    carrying the weight of one of the other Bambu catalogue rows, with the
    weights read out of the catalogue rather than hardcoded so an install
    whose rows have been re-measured is repaired to its own numbers. Keying on
    whether a catalogue row had been recorded would not have worked -- the
    weight picker auto-selects the only row matching the weight and writes its
    id on the next save, so that column says only whether the form was ever
    opened. The one case that cannot be told apart is stated rather than
    hidden: someone who moved an RFID roll onto a genuine High Temp spool and
    set 216 g by hand is normalised with the rest. Runs exactly once, so a
    tare set afterwards is kept.
2026-08-23 14:25:45 +02:00
maziggy 87e0a4c3b6 Name a spool by its subtype on the slot it is assigned to
A spool's subtype is half of what it is called: "PLA" and "PLA Wood" are
    different filaments. The AMS slot's hover card built the assigned-spool
    line out of brand, material and colour name and left the subtype out, so
    a roll of Bambu PLA Wood Classic Birch in an H2C's A4 was announced as
    "Bambu Lab PLA - Classic Birch".

    Everything else named it correctly at the same moment -- the RFID read,
    the inventory row, the slot's own profile line, which is built from the
    spool's slicer preset rather than reassembled, and Bambu Studio -- so the
    one wrong line read like a bad tag read rather than a display fault.

    It was not only the render. The card's assignedSpool prop had no subtype
    field at all, and the six places the printer card fills it in -- regular
    AMS, AMS-HT and external spool, each in both Spoolman and internal-
    inventory mode -- never passed one, so the value could not reach the
    component. The field is required rather than optional, which is what
    stops the next call site from quietly omitting it; that omission is the
    whole of this bug.

    Three more surfaces rebuilt the name the same way and are fixed with it:
    the SpoolBuddy AMS slot panel in both inventory modes, and the write-tag
    confirmation. Every other place a spool is named -- the assign dialogs,
    the inventory cards, the forecast rows, the label picker -- already
    included the subtype, so these four were the outliers.

    This is the display-side half of #2902, which stopped the backend
    reducing a filled or foamed filament onto its base material. The card was
    doing the same thing to the same spools, one layer further out.
2026-08-23 13:52:32 +02:00
maziggy 490f72fe21 Updated CHANGELOG 2026-08-23 13:07:17 +02:00
maziggy 22ede4a128 Split the Windows installer build so signing can wait for approval
The SignPath Foundation production certificate does not sign on demand
    the way the self-signed test certificate does. Every request has to be
    approved by hand in the SignPath UI, because the Foundation verifies
    what is being signed and which build produced it. The submitting action
    waits for that approval with a default timeout of 600 seconds, which is
    ample while the test policy approves in seconds and far too short once
    the wait is a person noticing a tag went out. A tag pushed at night
    would have failed the run ten minutes later with the installer already
    compiled and thrown away.

    The compile now ends in its own job, which uploads the unsigned artifact
    and exposes its id. A second job downloads it, signs it, and does the
    release-facing work, with the wait raised to an hour and the job timeout
    sized to sit outside it. Separating them is what buys the recovery: the
    artifact is uploaded before the wait begins and is addressed by id, so a
    missed approval window costs a re-run of the second job alone rather
    than a rebuild. Raising the timeout in place would not have given that.

    The second job runs for unsigned builds too. Daily prereleases are
    deliberately left unsigned to preserve the signing quota, and gating the
    whole job on the signing decision would have meant a second copy of the
    alias, artifact and release steps for them to run through.

    The decision itself moves into a named step that echoes it, so a tag
    that came out unsigned can be explained from the run log rather than by
    re-reading the expression. It is one source of truth feeding both jobs,
    which a job-level env could not be.

    Every step body is otherwise unchanged. The property worth keeping is
    that none of the alias, upload and release-attach steps carry always(),
    so GitHub skips all three when signing fails or times out and an
    unsigned .exe cannot reach a release; that is now written next to them,
    because it is easy to break by adding a condition without noticing.

    The policy slug stays at test-signing and the signature check stays
    lenient -- the test certificate is self-signed and reports UnknownError,
    so requiring Valid would fail every run until the production certificate
    is imported. Both are the cutover. The restructure behaves identically
    under the test policy, the request simply completing at once instead of
    waiting, so it can be proven green beforehand.
2026-08-23 12:47:25 +02:00
maziggy f27b82355a Updated BACKERS 2026-08-23 12:47:11 +02:00