Commit Graph
4231 Commits
Author SHA1 Message Date
maziggy 0cbda2a7f0 Updated BACKERS 2026-09-20 13:24:44 +02:00
maziggy 0ed86c9802 (security): Bumped fflate to 0.8.3 for a denial-of-service advisory reachable through three's compressed-format loaders (GHSA-px8p-9vwx-vf98, #3034) 2026-09-20 13:24:29 +02:00
maziggy 9fed80073d Merge pull request #3034 from maziggy/dependabot/npm_and_yarn/frontend/npm_and_yarn-c46cce24cb 2026-09-20 13:24:13 +02:00
maziggy 7b507103e0 deps(frontend): bump browserslist and @humanfs/node for dev-scope advisories 2026-09-20 13:23:50 +02:00
maziggy 3e6896de2d deps(frontend): move the Tiptap stack to 3.31.1
GHSA-cp6q-959q-f8rh: @tiptap/core's mergeAttributes() copies keys out of
    Object.entries() with plain bracket assignment, so an own __proto__ key
    from JSON hits the legacy prototype setter rather than writing a
    property. The result carries an attacker-controlled prototype while
    Object.keys() and own-property checks show nothing, and ProseMirror's
    DOMSerializer.renderSpec() enumerates attribute objects with for...in --
    so inherited src and onerror land on a rendered <img> and execute.
    Medium, CVSS 4.0 6.4, fixed in 3.30.4.

    Not reachable here. The advisory needs an untrusted object arriving at
    mergeAttributes(), or a custom or dynamic extension that preserves the
    attribute object. Nothing under frontend/src calls mergeAttributes or
    defines an extension, and RichTextEditor builds a fixed schema from
    StarterKit plus six stock extensions whose HTMLAttributes are static
    literals. Content crosses as an HTML string rather than JSON, so no own
    __proto__ key reaches an attrs object at all -- the DOM parser only
    fills attributes the schema declares -- and every read-only render is
    sanitized.

    Lockfile only: package.json already declared ^3.11.1, so the patched
    line was inside the range and only the stale lock held 3.19.0. No
    overrides entry needed.

    @tiptap/pm has narrowed its dependency set, so prosemirror-markdown,
    prosemirror-menu, prosemirror-collab, prosemirror-schema-basic,
    prosemirror-trailing-node, markdown-it and linkify-it leave the tree --
    16 packages, none imported by this repo. That retires the reachability
    note carried for linkify-it in 1.2.5.

    eslint, build with the Safari 16 baseline check, i18n parity and 3514
    frontend tests across 256 files all pass. npm audit --omit=dev, which
    is what CI gates on, reports zero vulnerabilities.
2026-09-20 13:23:32 +02:00
maziggy 7490c93081 ci: balance the backend test shards by measured time, not test count
Backend Tests (shard 1/4) timed out after 10 minutes on e8b901f54
    ("Updated BACKERS"), a docs-only commit. The step was not hung: it
    reached 98%, every test passing, and was killed about 12 seconds short
    of finishing.

    pytest-split balances by duration only when it has a durations file,
    and there was none. Without one it splits by test COUNT -- 2926 / 2926
    / 2926 / 2925, exactly 25% each, which is what the comment here claimed
    was good enough. Count is not time. Measured over the full suite
    (11703 tests, 1045.6s):

        shard 1   2926 tests   658.2s
        shard 2   2926 tests   173.1s
        shard 3   2926 tests   113.3s
        shard 4   2925 tests   101.1s

    Shard 1 was carrying 63% of the suite's runtime -- a 6.5x spread -- and
    had been walking toward the cap for weeks: 475s, 404s, 445s, then 587s
    on 08-30, thirteen seconds under it, and 613s here. CI printed the
    reason in its own log on every run: "[pytest-split] No test durations
    found."

    Commit tests/.test_durations and pass --durations-path explicitly:

        shard 1   1273 tests   261.6s
        shard 2   1070 tests   261.5s
        shard 3   1522 tests   262.0s
        shard 4   7838 tests   260.6s

    A 1.01x spread. The lopsided test counts are the point: shard 4 takes
    thousands of fast unit tests, shard 1 keeps the slow integration ones.
    The four groups still partition the suite exactly -- union is 11703
    with nothing dropped or duplicated, and all four pass.

    --durations-path has to be explicit because pytest-split defaults it to
    $CWD/.test_durations, and the two matrices run from different
    directories: the native job from backend/, the Docker job from the
    image root. Left implicit, the Docker one finds nothing and silently
    falls back to the count split. Node IDs match across both because
    backend/tests/pytest.ini pins rootdir to backend/tests either way, so a
    single file serves them both; verified the file clears .dockerignore
    and lands at /app/backend/tests/.test_durations at full size.

    timeout-minutes 10 -> 15 for headroom, since the file goes stale as
    tests are added. Staleness degrades slowly rather than breaking --
    unknown tests are treated as average.
2026-09-20 13:22:17 +02:00
maziggy 35405dd79e Updated BACKERS 2026-09-20 13:21:53 +02:00
maziggy 5584dca898 Bumped version 2026-09-20 13:19:02 +02:00
maziggy f879dd552a Updated BACKERS 2026-09-04 14:07:05 +02:00
maziggy 4b857d9e06 (security): Bumped fflate to 0.8.3 for a denial-of-service advisory reachable through three's compressed-format loaders (GHSA-px8p-9vwx-vf98, #3034) 2026-09-04 14:02:56 +02:00
MartinNYHC 3fb41a709a Merge pull request #3034 from maziggy/dependabot/npm_and_yarn/frontend/npm_and_yarn-c46cce24cb 2026-09-04 14:01:17 +02:00
maziggy abf75e836b deps(frontend): bump browserslist and @humanfs/node for dev-scope advisories 2026-09-03 12:29:13 +02:00
maziggy 8f7f18b3c2 deps(frontend): move the Tiptap stack to 3.31.1
GHSA-cp6q-959q-f8rh: @tiptap/core's mergeAttributes() copies keys out of
Object.entries() with plain bracket assignment, so an own __proto__ key
from JSON hits the legacy prototype setter rather than writing a
property. The result carries an attacker-controlled prototype while
Object.keys() and own-property checks show nothing, and ProseMirror's
DOMSerializer.renderSpec() enumerates attribute objects with for...in --
so inherited src and onerror land on a rendered <img> and execute.
Medium, CVSS 4.0 6.4, fixed in 3.30.4.

Not reachable here. The advisory needs an untrusted object arriving at
mergeAttributes(), or a custom or dynamic extension that preserves the
attribute object. Nothing under frontend/src calls mergeAttributes or
defines an extension, and RichTextEditor builds a fixed schema from
StarterKit plus six stock extensions whose HTMLAttributes are static
literals. Content crosses as an HTML string rather than JSON, so no own
__proto__ key reaches an attrs object at all -- the DOM parser only
fills attributes the schema declares -- and every read-only render is
sanitized.

Lockfile only: package.json already declared ^3.11.1, so the patched
line was inside the range and only the stale lock held 3.19.0. No
overrides entry needed.

@tiptap/pm has narrowed its dependency set, so prosemirror-markdown,
prosemirror-menu, prosemirror-collab, prosemirror-schema-basic,
prosemirror-trailing-node, markdown-it and linkify-it leave the tree --
16 packages, none imported by this repo. That retires the reachability
note carried for linkify-it in 1.2.5.

eslint, build with the Safari 16 baseline check, i18n parity and 3514
frontend tests across 256 files all pass. npm audit --omit=dev, which
is what CI gates on, reports zero vulnerabilities.
2026-09-03 12:09:54 +02:00
maziggy e0a2398783 ci: balance the backend test shards by measured time, not test count
Backend Tests (shard 1/4) timed out after 10 minutes on e8b901f54
("Updated BACKERS"), a docs-only commit. The step was not hung: it
reached 98%, every test passing, and was killed about 12 seconds short
of finishing.

pytest-split balances by duration only when it has a durations file,
and there was none. Without one it splits by test COUNT -- 2926 / 2926
/ 2926 / 2925, exactly 25% each, which is what the comment here claimed
was good enough. Count is not time. Measured over the full suite
(11703 tests, 1045.6s):

    shard 1   2926 tests   658.2s
    shard 2   2926 tests   173.1s
    shard 3   2926 tests   113.3s
    shard 4   2925 tests   101.1s

Shard 1 was carrying 63% of the suite's runtime -- a 6.5x spread -- and
had been walking toward the cap for weeks: 475s, 404s, 445s, then 587s
on 08-30, thirteen seconds under it, and 613s here. CI printed the
reason in its own log on every run: "[pytest-split] No test durations
found."

Commit tests/.test_durations and pass --durations-path explicitly:

    shard 1   1273 tests   261.6s
    shard 2   1070 tests   261.5s
    shard 3   1522 tests   262.0s
    shard 4   7838 tests   260.6s

A 1.01x spread. The lopsided test counts are the point: shard 4 takes
thousands of fast unit tests, shard 1 keeps the slow integration ones.
The four groups still partition the suite exactly -- union is 11703
with nothing dropped or duplicated, and all four pass.

--durations-path has to be explicit because pytest-split defaults it to
$CWD/.test_durations, and the two matrices run from different
directories: the native job from backend/, the Docker job from the
image root. Left implicit, the Docker one finds nothing and silently
falls back to the count split. Node IDs match across both because
backend/tests/pytest.ini pins rootdir to backend/tests either way, so a
single file serves them both; verified the file clears .dockerignore
and lands at /app/backend/tests/.test_durations at full size.

timeout-minutes 10 -> 15 for headroom, since the file goes stale as
tests are added. Staleness degrades slowly rather than breaking --
unknown tests are treated as average.
2026-08-31 17:13:09 +02:00
maziggy e03271215b Updated BACKERS 2026-08-31 12:08:19 +02:00
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
MartinNYHC 029c3ac4b7 Updated CHANGELOG 2026-08-28 12:52:56 +02:00
MartinNYHC 8cce10b60a Skip the manual K calibration the way the automatic one is skipped
Bambuddy already recognises the printer's automatic pressure-advance run,
    auto_pa_line_calib_mode, and leaves no archive and sends no notification
    for it. Started by hand rather than automatically before a print, the
    same calibration reports under a different name: it prints a pattern
    where the automatic one prints a line, and carries no auto_ prefix, so
    pa_pattern_calib_mode matched nothing.

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

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

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

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

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

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

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

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

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

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

    Two more defects from the same log.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    The fake in test_spoolman_extra_field_registration_2903 modelled the
    per-field path as a working probe, which is the assumption this bug was
    built on; it now answers 405 as the real server does, and its assertions
    follow the listing. 18 new tests cover the rest, 10 of which fail against
    the old code.
2026-08-28 12:43:58 +02:00