2.1 MiB
Changelog
All notable changes to Bambuddy will be documented in this file.
[1.2.5] - 2026-07-24
Added
- Skip Objects can now be selected directly on the top-down build plate — The skip dialog pairs the plate preview with the slicer's exact per-object pick mask, so clicking a model selects the same object id the printer firmware expects. Multiple objects can be selected before one confirmation, selected and already-skipped items are highlighted on the plate, and the checklist remains available when a pick mask is missing. Selecting every remaining object keeps the printer's existing stop-print warning, and the dialog closes once the skip is confirmed. Confirming names the object when one is selected and counts them when several are, which is what plates of identically-named clones need. The existing layer, permission, and printer-command guards are unchanged.
- Assigning a spool to an AMS slot now tells you whether the printer actually accepted it (#2582, reporter @gyrene2083) — Until now, assigning a spool to an AMS tray was fire-and-forget: Bambuddy pushed the filament setting to the printer and immediately reported success, whether or not the tray took it. When the assignment silently didn't land — the reporter's case, where a spool assigned in Bambuddy never showed up in Bambu Studio — nothing told you, and the only way to tell it had loaded was to run a flow calibration and watch for the K-profile to appear. Because a print only deducts filament from the spool assigned to the exact tray it pulls from, a silently-dropped assignment also meant that print recorded no filament usage, which is what made the whole thing feel random. Bambuddy now reads the AMS telemetry back after every assignment (from both Printers → assign spool and Configure Slot) and toasts the outcome: "Filament loaded on slot X" once the tray echoes back the filament id that was pushed, a warning if the filament loaded but the flow-calibration (K-profile) wasn't applied, or "couldn't confirm the assignment — check the AMS slot" if the tray never reflects it within ~30s. The confirmation is derived entirely from the periodic status the printer already sends (an on-demand pushall is nudged so it lands quickly), covers regular AMS, AMS-HT, and external-spool slots, and if the printer goes silent it simply stays quiet rather than inventing a failure. No configuration; the toast appears automatically on assign.
- Bed levelling, flow calibration, and nozzle-offset calibration now have an "Auto" option, matching Bambu Studio — These three print options were previously on/off only, so the only way to run bed levelling was to force a full level before every print. Bambu Studio has long offered a third "Auto" state that lets the printer skip the calibration when it was done recently, and that state is what most people actually want. All three options (in the Schedule/Print dialog, the queue bulk-edit, and Settings → Workflow → Default Print Options) are now a three-way Off / Auto / On choice, and new prints default to Auto. "On" still forces the calibration every time; "Off" skips it entirely; "Auto" lets the printer decide. Existing queued prints and your saved workflow defaults are migrated automatically — anything that was "on" becomes "On (force)" and anything "off" stays "Off", so nothing changes for in-flight jobs until you opt into Auto. The wire encoding mirrors Bambu Studio's exactly (verified against its source), including how prints sent through a Virtual Printer inherit the slicer's own Auto/On/Off pick.
Changed
- Orca Cloud profile sync now connects by approving a code instead of the copy-paste sign-in — Connecting Bambuddy to Orca Cloud used to mean opening an OAuth sign-in in a new tab, watching it redirect to a
localhostURL that fails to load, then copying that dead URL out of the address bar and pasting it back into Bambuddy. That dance existed only because Orca's auth backend (Supabase) accepts no redirect target other thanlocalhost, and the deliberately-broken redirect page confused nearly everyone who reached it. OrcaSlicer has since shipped a first-class external-app pairing API (the OAuth 2.0 Device Authorization Grant, RFC 8628), so the flow is now: click Connect, approve a short code on your Orca Cloud settings page, and Bambuddy pairs itself — no redirect, no paste, no client secret, and it behaves identically from a LAN IP,localhost, or behind a reverse proxy. Bambuddy requests read-only access (it only lists and views your Orca Cloud profiles), keeps the pairing alive with the API's rotating refresh tokens (validated end-to-end against Orca's staging and production servers), and stores nothing beyond the issued token pair. The profile list and detail views are unchanged, so nothing downstream of the connect step looks different. The old paste-based sign-in and the email/password fallback are removed. Points at production Orca Cloud by default;ORCA_CLOUD_API_BASEoverrides the endpoint for testing.
Fixed
- The AMS slot popup stayed on screen and covered the filament dialog it had just opened (#2631, reporter @Jostxxl) — Tapping Configure on an AMS slot left the slot's popup standing on top of the filament type/colour dialog, so the two layers overlapped: obscured content, competing backdrops, and controls of one layer sitting over the other. Most disruptive in the tablet operator workflow, where the printer is opened straight from a plate-clear scan and the next step is setting the loaded filament. Root cause. The slot popup is portaled to the page body at
z-[60], deliberately, so it can escape the stacking contexts that sibling printer cards create on the dashboard (#1336) — which also puts it above the Configure Slot and Link Spool dialogs atz-50. Nothing dismissed it: the popup is hidden only by the pointer leaving it, and a touch device never sends that event after the tap that opened it, so on a tablet it simply stayed up. On a desktop it self-cleared as soon as the mouse moved off the popup's bounds, which is why this only ever showed up on touch. Fix. The popup now closes itself before running any action that opens a dialog or navigates away — Configure, Assign Spool, Unassign Spool, and both Open in Inventory links, on loaded and empty slots alike — so exactly one dialog is ever on screen. Actions that report progress inside the popup (RFID re-read, Load, Unload, and Copy UUID with its confirmation tick) are deliberately unchanged, since none of them opens a dialog and closing would take their feedback with it. Covered by hover-card tests for loaded and empty slots: the popup is gone after each action, the action still fires, and it does not reappear once a pending open-timer elapses. - Slicing a single plate failed on a filament slot the plate doesn't even use, with no way to fix it from the UI (#2628, reporter @michaelklos) — Slicing plate 2 of a local multi-plate 3MF for an A1 failed with "filament preset (slot 1) is not compatible with printer Bambu Lab A1 0.4 nozzle". Slot 1 was labelled "Filament 1 (PLA) — not used by this plate", held
SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle, and its dropdown was greyed out — so the slice was blocked by a slot the plate never touches and the user couldn't correct. Slicing all plates worked. Root cause, two independent defects. (1) The unused-slot substitution — which replaces the profile in every slot the plate doesn't paint with, so the slicer's validators don't judge the slice on slots its G-code never touches — always copied from slot 1. When slot 1 is itself the unused one, that's a no-op (the reporter's log even shows it:Substituted slot-1 filament for unused slot(s) [1]), and with several unused slots it actively spread slot 1's foreign profile across all of them — the same poisoning #1851 removed from the picker. (2) The dialog's printer-compatibility matcher only understood BambuStudio's short@BBL <model>tag. Profiles you save yourself carry the full printer name instead (… @Bambu Lab H2D 0.4 nozzle), which the matcher classified as "can't tell" — indistinguishable from compatible — so an H2D-scoped filament was offered in the main dropdown list, auto-picked for the slot on metadata score, and handed to the slicer. Fix. The substitution now copies from the plate's lowest used slot, so an unused slot can no longer block a slice regardless of what was baked into the source file; if a plate's used slots fall outside the submitted list, the picks are left untouched rather than substituted from a slot the plate doesn't use. And the matcher now reads both tag shapes — the same two forms the AMS slot dialog has parsed since #1623 — including a trailing(Custom)suffix and a stray earlier@in the name, so a profile scoped to another printer is never auto-picked and is grouped under Other printers where you can still choose it deliberately. A profile whose tag names no recognisable Bambu printer stays unclassified and keeps its place in the list, exactly as before. Covered by matcher tests (long-form mismatch and self-match, display-name-vs-short-code models, the nozzle filter,(Custom)suffix, the A1/A1 Mini alias, unrecognisable tags, stray@), picker tests (the reporter's registry: the H2D profile loses to the A1 one despite a better colour and tier score, but still wins for its own printer), and substitution tests (anchors on the first used slot, doesn't poison sibling unused slots, deterministic lowest-used anchor, support slots as anchor, and the out-of-range no-op). - A filament profile could be auto-picked for a printer it doesn't belong to when its name doesn't say which printer that is (#2628 follow-up) — Slicing for a P2S failed with "filament preset Bambu PLA Basic @BBL X1C 0.2 nozzle (slot 1) is not compatible with printer Bambu Lab P2S 0.4 nozzle" — naming a profile that appeared nowhere in the slice dialog. The dialog showed
Overture PLA Matte @0.2in every slot, including the one the plate actually uses; the slicer resolves that profile's inheritance chain and validates the X1C system profile at its root. Root cause. The dialog decides whether a profile fits the selected printer from the profile's owncompatible_printerslist, and falls back to reading the printer out of its NAME. This profile's name carries a nozzle size but no model, so the name fallback couldn't classify it — and the list, though present on the imported copy, is not shipped by every source: Bambu Cloud omits it from its listing on purpose (the per-profile endpoint is rate-limited), and Orca Cloud's listing carried it but Bambuddy only read the filament type and colour out of it. "Can't tell" is treated as usable, so the profile scored its way into the auto-pick for a printer it was never built for. Fix. Orca Cloud profiles now surface their owncompatible_printers(it was already in the data Orca returns — no extra request), and the existing same-name bridge that lends Bambu Cloud entries their filament type and colour from another source now lends the compatible-printer list too, in both directions between the cloud sources. So whichever copy of a profile knows its printers teaches the ones that don't. As a last resort for profiles no source can classify, a bare@<size>tag in the name is now read as a nozzle size: it can rule a printer out (0.2 profile, 0.4 printer) but never rules one in, since a size says nothing about the model — and a number that can't be a nozzle (@2026) is ignored rather than guessed at. Profiles that still can't be classified keep their place in the list exactly as before; ones that can are grouped under Other printers, where you can still pick them deliberately. Covered by preset-listing tests (Orca list extraction incl. the bare-string form, malformed/empty lists staying unclassified, the bridge in both directions and for both process and filament, never overwriting a list an entry already has, no-donor staying unclassified, and the borrowed list being copied rather than shared across the per-user caches) and matcher tests (0.2-vs-0.4 rejection, matching size staying unclassified, the0.2 nozzle/0.2mmspellings, numeric0.20vs0.2, implausible sizes ignored, and model-bearing tags still taking the model path). - Switching off an accessory smart plug at the end of a print knocked the printer into "Unknown" and stalled the queue (#2629) — With an end-of-print auto-off on a plug that powers a filter fan (not the printer), the printer flipped to Unknown the moment the plug switched off and the queue stopped dispatching to it until a manual Force Refresh. The printer itself never went anywhere — MQTT traffic continued a second later. Root cause, two parts. (1) Bambuddy treats any plug linked to a printer as that printer's power supply: every auto-off — time delay, temperature delay, the time-of-day schedule, a resumed-after-restart off, and a manual off from the plug card — immediately marked the printer offline, whether or not the plug feeds it. (2) That mark was unrecoverable. It forces
connected=Falseand the state tounknown;connectedheals on the very next MQTT message, but the state does not — the printer state is only rewritten when a status frame carriesgcode_state, and the steady-state frames a P1S sends are partial. Sounknownstuck until the next full status push, and the scheduler (which dispatches only toIDLE/FINISH/FAILED) treated the printer as permanently unavailable. Fix. The offline mark is now an explicitly presumed power cut: the state it overwrites is remembered, and the presumption is undone as soon as the printer sends another report on its own topic, since inbound traffic proves the power was never cut (a frame that does carrygcode_statestill wins, and the recovery re-broadcasts so the UI un-greys). A printer whose power really was cut sends nothing, so it correctly stays offline — this also repairs the same stuck-unknownfor a genuine printer plug whose MQTT resumes without a full push. On top of that, each plug now carries a Powers the printer toggle (shown when a printer is linked, in both the plug card and the add/edit dialog): leave it on for the plug that feeds the printer, turn it off for accessories — filter fan, chamber light, enclosure heater — and switching those off no longer touches the printer's state at all. Existing plugs are migrated as power plugs, so nothing changes until you say otherwise. The same flag also fixes the queue's power-on step, which used to pick whichever linked plug came first and could spend the whole power-on timeout waiting for a filter fan to boot a printer; it now picks the plug flagged as the power source. Covered by an end-to-end regression test that drives the real MQTT client, printer manager and scheduler through the reported sequence (accessory off → printer keeps talking → queue dispatches again; real power cut → stays offline), MQTT-client tests (presumed off remembers and restores the state, a partial frame recovers it, a realgcode_stateoverrides it, request-topic traffic does not count as proof of life, a reconnect discards the saved state, and a genuine second power cut is not undone), smart-plug tests (accessory plugs switch off without marking the printer offline on all four off-paths, power plugs keep the old behaviour), scheduler tests (power-plug selection), and migration tests on both SQLite and PostgreSQL. - A2L "AMS Lite" slots showed as empty and never deducted filament — On an A2L with the 4-slot AMS Lite attached, physically loaded slots could display as empty, filament usage was never deducted from the loaded spool, and linking an AMS-Lite slot to a Spoolman spool failed outright. Root cause. The A2L reports its AMS Lite as unit id 16, but the firmware is internally inconsistent about it: its slot-presence bitmasks sit at the bit positions for unit 6 (bit base 24), and it reports the actively-feeding slot (
tray_now) as a local 0-3 index rather than a global tray id. Bambuddy'sams_id*4+slotconvention, fed the raw id 16, probed bit positions 64-67 (always zero) and marked every loaded slot empty; the localtray_nowwas read as a global id, so usage was attributed to the wrong spool (or dropped); and theams_id <= 7database constraint rejected id-16 Spoolman links. Fix. The AMS Lite is now normalised from unit 16 → 6 at the MQTT ingest boundary, so its global tray ids land at 24-27 — matching the firmware's own bit base, working with every existingams_id*4+slotconsumer (slot presence, usage tracking, deficit warnings, the scheduler, Load/Unload), colliding with nothing, and passing the database constraint. The localtray_nowis globalised to24+slotso deduction hits the right spool, the valid-tray guards accept the 24-27 range, and the printer card labels the unit "AMS Lite". Prints dispatched to the Lite build the correctams_mapping2({ams_id:16, slot_id:0-3}) and flat mapping (local 0-3), both confirmed against the firmware's own mapping. Outbound slot commands (set filament, reset, load/unload, calibration, RFID refresh) translate the normalised id 6 back to the physical 16 on the wire, centralised in one helper. The whole normalisation is self-scoping — only unit id 16 is ever touched, so every other printer and AMS type is byte-for-byte unaffected. Verified against the reporter's live captures (slot presence viatray_exist_bits, andtray_nowwhile printing a known physical slot); mixed setups (a regular AMS attached alongside the Lite) are out of scope and log a warning, and one uncaptured wire encoding (the physical global tray field onload/calibrationcommands) is extrapolated and isolated to the single translation helper. - Bambu Cloud kept dropping to "sign-in expired" and forcing constant re-logins, even while cloud features still worked — Since the #2562 status rework, the Bambu Cloud sign-in would flip to "expired" shortly after logging in, over and over, with nothing in the logs to explain it. Root cause. The rework made a genuine 401 from Bambu durably record the stored token as dead (
cloud_token_invalid_at) — correct in principle, but it treated any HTTP 401 from any cloud or MakerWorld call as a dead token. Bambu returns 401 for plenty of non-fatal reasons (an endpoint-, region- or scope-specific refusal; a Cloudflare-edge blip; a brief backend hiccup), so a single stray 401 from any one call — including a background poll — durably signed the whole cloud integration out until the next manual re-login. Because the flag lives in the database, a setup running more than one Bambuddy instance against the same database made it worse: a stray 401 seen by either instance signed the user out in both. Fix. Invalidation now fires only for Bambu's documented token-expiry response —{"code":4,"error":"Please login."}— and never for a plain or unparseable 401, which is treated as transient (the request fails, but the session is left signed in). The same signature gate is applied to the MakerWorld path, which shares the token. A genuinely expired token is still detected and surfaced exactly as before; what stops is the false "expired" on a working session. Covered by service tests for both engines:code:4and the "Please login." text invalidate, while a benigncode:1/forbidden401, an unparseable 401, and (for MakerWorld) a signature-less 401 with a token do not. - Every SpoolBuddy screen crashed the moment a text field was focused (#2616, reporters @MartinNYHC, @agentdr8) — Tapping the Search box on the SpoolBuddy inventory, or the Search / Color Name / Brand fields on the write-tag New Spool tab, blanked the UI with a minified React error #130 ("Element type is invalid… but got: object"). It hit both internal and Spoolman inventories, so it was not data-specific. Root cause. The SpoolBuddy shell mounts an on-screen keyboard (
VirtualKeyboard) that pops up onfocusinfor any text input — which is why every field on every SpoolBuddy page tripped it, while the main app (no on-screen keyboard) was fine. That component doesimport Keyboard from 'react-simple-keyboard', a CommonJS package, and under the current bundler's CJS→ESM interop the default import resolves to the module namespace object ({ KeyboardReact, default }) rather than the component itself. Rendering that object as<Keyboard>put an object where an element type belongs, and React threw. (The discrepancy is interop-specific: the test runner hands back the real component, so it only manifested in the browser build — which is why it needed a runtime, not a type, fix.) Fix. A smallresolveInteropDefaulthelper unwraps such an interop-wrapped default: it returns the value as-is when it's already a usable element type (function/class, tag string, or a$$typeof-marked forwardRef/memo/lazy) and otherwise falls through to.defaultand named exports.VirtualKeyboardresolves the realreact-simple-keyboardcomponent through it, so the keyboard renders under any interop shape. Covered by unit tests for the resolver against the object shape, a named-only export, a forwardRef object, and a bare component, plus a render test that mounts the keyboard on input focus. - The streaming overlay (
/overlay) showed nothing in OBS when login was enabled (#2613, reporter @MartinNYHC) — With authentication on, the overlay page worked when opened in a browser where you were already signed in, but stayed blank in OBS. The reporter suspected their Cloudflare/remote setup; it was unrelated. Root cause. The/overlay/{id}route renders without a login, but every piece of data it draws is auth-gated — printer status and name (PRINTERS_READ), one setting (SETTINGS_READ), and the camera stream (a camera-stream token). In your own browser those ride the JWT from local storage and the app-wide stream-token sync; OBS is a fresh browser with no session, so the status calls 401'd and the overlay never populated (the same would happen in any private/incognito window — remote access was never the cause). Unlike the Cam Wall (/camwall?token=…), the overlay had no token mode, and a long-lived token couldn't help because the JWT-gated status/settings endpoints reject it. Fix. The overlay is now a self-contained kiosk surface. A new Streaming Overlay long-lived-token scope is offered under Settings → API Keys (with a ready-made/overlay/{id}?token=…URL copied once on creation); the overlay page reads?token=from the URL and, in that mode, authenticates its status and camera calls with the token instead of a JWT (and skips the WebSocket, falling back to its existing 2 s poll). A new token-authenticatedGET /printers/{id}/overlay-statusreturns exactly the fields the overlay draws — name, camera rotation, live print state, and the one setting — and nothing else. The scope is deliberately separate fromcamwall: the overlay names the file on screen, which the Cam Wall is trusted never to expose, so folding it in would have silently widened every existing wall token. The logged-in path (opening the overlay while signed in) is unchanged. Docs updated to explain the token and stop claiming the overlay needs no authentication. Covered by backend tests (scope boundaries in both directions — an overlay token can't reach the Cam Wall feed and a camwall/camera-stream token can't reach the overlay feed — plus the payload shape, disconnected-printer shape, and revoked/absent/garbage-token rejection) and frontend tests (kiosk mode reads the token feed and carries the token to the camera, never touches the JWT-only status endpoint or a socket; the mint UI offers the scope and hands over the assembled OBS URL). - Reassigning a queue item while it was dispatching split it across two printers (#2615, reporter @Jostxxl) — Editing a queue item's printer while its FTP upload was already in flight left the queue row pointing at one printer while the archive, expected-print registration, and the physical
project_filecommand had gone to another. On a farm this made the reassigned-to printer look broken (markedprintingbut never sent the job), left the row permanently inconsistent, and could trigger a duplicate dispatch after a restart. Root cause. A queue row staysstatus='pending'for the entire (multi-minute) FTP upload — status only flips toprintingat the very end. The edit route only blocked non-pendingrows, so aPATCHduring the upload window was accepted; the in-flight dispatch kept using the printer it had snapshotted at the start, while the DB row'sprinter_idchanged underneath it. The existing #1853 CAS guards cancellation mid-dispatch, not reassignment. Fix. Adispatching_atclaim is stamped atomically on the row (WHERE status='pending' AND dispatching_at IS NULL) the moment the scheduler begins dispatching, before any slow I/O, and cleared when dispatch ends. While it's held, both edit routes reject changes — the single-itemPATCHreturns 409 (re-checked immediately before the write to close the read-modify-write gap), and bulk edits skip the row — and the scheduler's selection query won't re-pick it. Startup reconciliation clears any claim orphaned by a crash mid-dispatch (no dispatch coroutine survives a restart, so every claim present at boot is stale), so a stale token can never wedge an item out of the queue. The row stayspendingthroughout, so no status-consumer, UI, completion, or reconciliation path had to change. To move a dispatching item, cancel it first (the coordinated escape) and re-queue. New columnprint_queue.dispatching_at(nullable timestamp, dialect-safe DDL — SQLiteDATETIME/ PostgresTIMESTAMP). Covered by scheduler tests (claim is exclusive, fails on non-pending rows, releases on every exit, skips an already-claimed row, startup clears stale claims) and API tests (reassign returns 409 withprinter_idunchanged, bulk skips the claimed row, an unclaimed pending row still edits normally). - A single plate printed from a multi-plate 3MF recorded the whole file's filament in statistics (#2614, reporter @Jostxxl) — Dispatching one selected plate of a sliced multi-plate 3MF through the queue could log the entire file's filament against that one plate. The reporter's
heart 3.gcode.3mfhas 22 plates totalling ~12.0 kg; every completed plate recorded12006.49 g, so 13 runs inflated lifetime/user/project/filament stats by ~156 kg from one file. Root cause. The per-run value written toPrintLogEntry.filament_used_gramsprefers the AMS-tracked spool delta, but when the tracker measured nothing (no inventory assignment on the printer) a completed run fell back toPrintArchive.filament_used_grams— which is deliberately the sum over every plate of the source 3MF (correct for the archive card and project rollup, #1593). The archive'splate_id(persisted by #2603) was never consulted on this path, so the whole-file total was copied verbatim;costhad the same defect, falling back to the whole-filearchive.cost. Forward fix. When the archive carries aplate_idand its 3MF is on disk, the completed-run fallback now uses that plate's own slicer estimate (extract_plate_metadata_from_3mf, the same plate-scoped parse the inventory tracker uses), and scales cost by the plate's share of the whole. The tracker-measured path is unchanged (measured spool deltas still win) and single-plate archives are unaffected (plate value equals the whole-file value). Backfill. A startup migration repairs rows already written: for completed print-log entries whose stored grams exactly equal the linked archive's whole-file value (the mis-copy signature) and whose archive has aplate_idand an on-disk 3MF, it recomputes the plate-scoped grams + cost. The exact-match guard means tracker-measured rows (a rounded spool-delta sum) and partial-progress rows (scaled to progress) are never touched; it's idempotent (a corrected row no longer matches) and data-only, identical on SQLite and Postgres. Logs how many rows and how many grams of over-count it removed. Covered by unit tests for the forward helper (plate scoping, cost scaling, fallbacks when there's no plate_id / no file / unreadable estimate) and the backfill (mis-copy repaired, tracker/partial rows untouched, missing-3MF skipped, single-plate not relabelled, idempotent). - Progress notification ran off the left edge of the screen in the installed iPhone PWA (#2612) — On an iPhone 13 Pro with Bambuddy added to the Home Screen, the print-dispatch progress toast was clipped off the left side of the display — text like "prints", "plate_6", and "MB (21.0%)" bled past the edge. Root cause. The toast viewport is anchored
right-20(80 px from the right, to clear the bug-report bubble) and the dispatch toast has a fixedw-[420px]. On a phone that's 390 CSS px wide, 420 + 80 overflows the left edge by ~110 px — the toast simply didn't fit. Fix. Every toast now carries a viewport-relativemax-width(calc(100vw - 6rem - safe-area insets)) so it can never exceed the screen; on desktop the 420 px still wins. The viewport's position is also now safe-area-aware (env(safe-area-inset-*)on bottom/right) so an installed PWA clears the home indicator and a landscape notch, and the per-job filename row getsmin-w-0/shrink-0so long names truncate instead of pushing the toast wide at the narrower phone width. Frontend-only; no backend, schema, or i18n change. Covered by a test pinning the viewport-relative width cap. - Multi-plate queue prints lost the selected plate in Print History and a stopped-while-offline print stayed "printing" (#2603, reporter @Jostxxl) — Cancelling a print queued from a specific plate of a multi-plate 3MF showed it in Print History as Plate 1, so you couldn't tell which plate to requeue. Root cause. The archive derives its plate from the filename, but a whole multi-plate 3MF uploads under one name with no plate suffix, so the parser defaulted to plate 1 and
extra_dataheld all-plates aggregate metadata; the queue row kept the correct plate but nothing copied it onto the archive, which had no plate field at all. Fix.print_archivesgains a nullableplate_id, copied from the queue item at dispatch (both the archive-based and library-file paths), exposed in the archive API, and rendered in Print History (falling back to no plate label only when genuinely unknown). A startup backfill copies the plate onto existing archives from their linked queue rows, so already-cancelled prints recover their plate. Additionally, stopping a printing item while the printer was offline left the linked archive stuck at "printing" — the queue row was cancelled but, with no printer to send an MQTT completion, nothing ever reconciled the archive. The offline-stop path now closes the archive out directly (statuscancelled,failure_reason"Stopped by user (printer was offline)"); the online path is unchanged and still leaves the archive to the MQTT completion event. Column add + backfill are identical on SQLite and Postgres. Covered by tests for plate persistence, the backfill (including no-clobber/idempotency), and the offline vs online stop reconcile. queue_max_concurrent_uploadsbehaved as a per-batch cap instead of a refillable pool (#2602, reporter @Jostxxl) — On a large farm, unused upload slots sat idle whenever any upload from the current batch was still running. Root cause.check_queue()awaited_dispatch_selected(), which awaitedasyncio.gather()over the whole selected batch before returning — so the scheduler's run loop was blocked until the slowest FTP transfer in the batch finished. A 96 MB 3MF that took 513 s to upload left 15 of 16 configured slots unused for 8.5 minutes on a 93-printer farm, even as other printers came free; jobs that became eligible during the long upload couldn't be dispatched. The batch-await was load-bearing for one reason:_start_printflips a rowpending → printingonly after its upload completes, so returning early would have let the next pass re-dispatch the still-pendingin-flight rows. Fix. Uploads now run as independent background tasks tracked in a_inflightpool. Each tick excludes in-flight item rows (and their printers) from selection, launches at mostlimit − len(_inflight)new uploads, and returns immediately — so a freed slot refills on the next fast (3 s) tick instead of waiting out the whole batch, and the configured limit finally behaves as a continuously-refillable worker pool. The no-double-dispatch invariant the batch-await used to provide is now carried by the in-flight exclusion; thepending → printingCAS, the busy-printer guard (#2598), the per-printer dispatch hold, auto-drying exclusion (in-flight printers stay out, including on the no-pending-items path), and per-item failure isolation are all preserved and run per task. Investigated with the reporter's large-farm hotfix and reproduction; covered by rewritten pool tests (cap holds across refills, freed slot refills, in-flight item/printer excluded from re-selection, check_queue returns without awaiting uploads).- Configuring a built-in/generic filament on an AMS slot reverted to the old profile a moment later (#2604, reporter @Jostxxl) — Selecting a built-in preset (e.g. Generic ABS) through Printer → AMS slot → Configure briefly showed the new material on the printer, then the slot snapped back to whatever was there before (e.g. an old Generic PETG). Root cause. The Configure AMS Slot modal sends built-in, local, and Orca-generic presets with a
GF*tray_info_idxbut an emptysetting_id(those presets carry no Bambu Cloud setting id of their own), and theconfigure_ams_slotroute forwarded that empty value straight toams_filament_setting. The firmware treats a slot that has a filament id but no setting id as half-configured: it accepts the update, then reverts to its previously stored profile. The inventory/assignment path already guards against this by deriving the setting id from the filament id, but the manual Configure path didn't, leaving two inconsistent code paths. Fix.configure_ams_slotnow back-fillssetting_idfrom the resolvedtray_info_idxviafilament_id_to_setting_idwhenever the client sent none (e.g.GFB99→GFSB99), mirroring the inventory path. Doing it server-side also protects API callers and any future frontend.P*user presets and already-GFS*values are left untouched, and an explicitly-suppliedsetting_id(including thePFUS*pair) still passes through unchanged. Covered by tests for the built-in empty-setting_idcase and the material-only generic-fallback case both publishing a derivedGFS*id. - The HT-A (AMS-HT) spool vanished a few seconds after every power-on (#2594, reporter @GuillaumeHouba) — On an H2C, the spool in the HT-A high-temp AMS on the left nozzle showed correctly with its RFID assignment on power-on, then disappeared seconds later; the regular AMS spools stayed. Root cause. The AMS merge in
_handle_ams_dataclears a tray when it receives a partial{id, state}update whosestate != 11— the rule that lets 4-slot AMS units (e.g. H2D) report an emptied slot with just{id, state}and notray_type(#784), where11= loaded. But an AMS-HT (single-tray high-temp dry box, unit id ≥ 128) reports its loaded tray asstate=9, not 11 — it doesn't feed filament into a shared buffer the way a 4-slot AMS does. So the partial{id:0, state:9}the printer sends for the HT tray on power-on was misread as "slot emptied," and Bambuddy wiped the tray'stray_type/ RFID / Spoolman assignment. The support log showed it plainly: every "state=9 (not loaded) — clearing stale tray data" was on AMS 128, never on the regular AMS unit 0 (which correctly reports 11). Fix. Thestate != 11 → emptyheuristic is now skipped for AMS-HT units (id ≥ 128); their differing single-tray state semantics mean a partial state update must not clear a present spool. A genuine HT spool removal still clears through the explicittray_type == ""update and thetray_exist_bitscleanup, both unchanged, and regular AMS behavior (id < 128) is untouched. Covered by tests for the HT tray surviving astate=9partial, the HT still clearing on an explicit empty, and the existing regular-AMSstate=9/10/11cases. - A start-print dispatched to an already-busy printer could cancel the running job (#2598, reporter @khaosdoctor) — On an A1 mini across a night of prints, jobs were cancelled with no apparent cause; debug logs showed Bambuddy sending
project_filetwice ~3 minutes apart with no completion between, and the printer answering0500_4004("Device is busy and cannot start a new task") — which on that model cancels the RUNNING print. Root cause.start_print()in the MQTT client guarded only on connection state (self._client and self.state.connected) — it publishedproject_filewith no check on the printer'sgcode_state. The scheduler does gate dispatch on an idle check, but that check treatsFINISHas idle, and a printer can keep reportingFINISHfor tens of seconds after it accepted aproject_file; combined with a dispatch watchdog that reverts a queue item and releases its dispatch hold when it doesn't observe the active-state transition in time (#2555), a re-selected item could reach the FTP upload while the printer had actually started, so the start command landed on a live print. Fix (defense-in-depth). (a)start_print()now refuses to publishproject_filewhen the printer is in an active state (PREPARE/SLICING/RUNNING/PAUSE) and returns without sending — a single guard at the one publish choke point that every dispatch path (queue, manual, webhook, Virtual-Printer forward) funnels through.IDLE/FINISH/FAILEDremain valid start targets. (b) The scheduler re-checks the live printer state right before the FTP upload and defers a busy printer (leaves the item pending for a later tick) instead of uploading and dispatching — no wasted transfer, no collision. (c) If the printer goes busy during the upload window and the start command is refused, the scheduler reverts the item to pending (a deferral) rather than marking it failed — a busy printer is not a failure. Covered by tests for the client-level guard (refused while busy, published while idle, guard precedes the connection check) and the scheduler deferring both before the upload and after a busy-refused start. Note: a transport-level MQTT QoS-1 replay on reconnect would bypass the client guard, but the dispatch/watchdog reconnect path already hard-resets the client with a fresh session so it has no inflightproject_fileto replay. - Three more idle-in-transaction / thundering-herd paths surfaced by continued farm testing (#2572, reporter @Jostxxl) — On
origin/devwith 93 printers and multiple concurrent UI clients the reporter timestamp-correlated the surviving pool pressure to three remaining paths, none of them auth-related. (a) The scheduler held its per-item session across preheat and the FTP upload._dispatch_selectedopens oneasync_sessionper queue item and hands it to_start_print, which reads the printer/archive rows up front and then runs the preheat/heat-soak wait and the FTP delete+upload — all on the transaction opened by those firstSELECTs. One correlated session's last statement was asettingsSELECTat the exact moment the log showed "Starting queue item" → preheat → "FTP upload started" for a 96 MB 3MF; the transaction stayed open for the whole transfer. (This refines the earlier note that the scheduler paths were "already bounded" — the per-item session itself was the hold.)_start_printnow commits right before the FTP delete/upload, and_preheat_and_soakcommits after its read phase and before the up-to-15-minute soak wait (both loops touch onlyprinter_managerstate andasyncio.sleep, no DB).expire_on_commit=Falsekeeps the loaded rows readable; the status writes afterward (upload-failure path and the pending→printing CAS) transparently open a fresh transaction. (b)/cloud/filament-infoheld its request session across sequential Bambu Cloud round-trips and single-flighted nothing. The route took its session viaDepends(get_db)(held for the whole request), read the stored token, then looped over the uncached ids issuing one externalget_setting_detailHTTP call each — so the session sat idle-in-transaction across N cloud calls, and because the printer overview mounts one filament-info request per printer card, several browsers hit the same uncached preset at once and each issued its own cloud call. The route now releases the transaction (rollback) right after the token read and before the cloud loop (Phase 3's local-preset read reopens a fresh one), and concurrent misses for the same id single-flight through one shared cloud call. (c)/printers/{id}/coverhad no in-flight coalescing. The connection was already released before the download, but simultaneous clients could all miss the cache and each run the full multi-path FTP lookup + 3MF extraction (one observed transfer pulled an 81 MB 3MF while real print uploads were in flight). Identical concurrent cover requests now coalesce: the first becomes the leader and the rest await it, then serve from the positive/negative cache it filled. Also addspool_use_lifo(PostgreSQL default on,DB_POOL_USE_LIFOoverride, shown in/system/db-pool) so a bursty farm keeps a small hot connection set busy and lets excess overflow connections age out viapool_recycleinstead of churning the whole pool. Covered by tests: the scheduler releasing its connection before both the FTP upload and the soak wait, the filament-info single-flight (concurrent misses share one cloud call, cache-hit skips cloud, a failed fetch leaves no stuck in-flight entry), and concurrent cover requests downloading once. - An "Any [model]" queue job dispatched from a Virtual Printer printed to the empty external spool and aborted at layer 0 (#2595, diagnosed by @Sawtaytoes, PR #2596) — On a farm of identical X1Cs with different filaments loaded per AMS, the intended flow — VP in Queue mode, auto-dispatch, target Any X1C, force-colour-match picks the printer that has the right spool — sent the job to the correctly-matched printer and then failed: the printer ignored the AMS, pulled the empty external spool, and aborted with "not enough filament", even though the mapped slot was loaded (the same print via a specific printer, or straight from the slicer, worked). Root cause. A slicer talking to a Virtual Printer only ever sees the VP's external spool — a VP advertises no AMS — so the slicer sends
use_ams=false, and VP intake stamps that onto the queue item. But an "Any [model]" item is colour-matched to a real printer at dispatch, resolving a real AMS slot inams_mapping; the scheduler still forwarded the staleuse_ams=false. The print-command builder only ever forceduse_amsoff (the all-external case) and never back on, souse_ams=falseshipped alongsideams_mapping=[<real tray>]→ external spool → abort. Fix. For single-nozzle printers the resolved mapping is now authoritative: a real AMS tray (0-253) forcesuse_ams=true; an explicit external selection (254/255) still forces it false; an unresolved-1mapping does neither (preserving the #2589 contract — it should have been recomputed upstream, and must not be silently promoted to AMS or downgraded to external). Dual-nozzle printers are untouched, whereuse_amsencodes nozzle routing rather than an on/off flag. Because the correction lives at the single command-builder choke point, it fixes the VP, queue, and manual paths alike. Covered by tests for the VPfalse+real-tray promotion, padded mappings, all-external staying off, unresolved-1staying put, the original all-external downgrade, and the dual-nozzle bypass. - Reconnecting or restarting inflated Stats → Total Print Time by hundreds of hours on large farms (#2592, reporter @Jostxxl) — On the reporter's farm a restart pushed Total Print Time from ~1,500h to 3,215h. When a printer reconnects, the connected edge runs
reconcile_stale_active_prints, which closes out every archive still stuck instatus="printing"(missed completions, disconnects, restarts) by synthesising an abortedon_print_complete. That wrote aPrintLogEntrywhose duration wascompleted_at - started_at— but for a reconciled archive the real end time is unknown: the print stopped somewhere during the disconnect, andcompleted_atis only the reconnect moment. So each stale archive banked its entire multi-day gap as print time (one row was 51.9h), and a printer with several stale archives contributed hundreds of fabricated hours at once. Worse, the Stats total recomputedcompleted_at - started_atwhenever the stored duration was falsy, so storing NULL wouldn't have helped. Reconciled completions now log an explicitduration_seconds = 0(honest "no measured runtime") and the two Stats time paths trust a stored 0 instead of recomputing from the stale timestamps — legacy rows that never recorded a duration still fall back as before. Reconciled aborts also get a truthfulfailure_reason("Stale - reconciled after reconnect, end time unknown") instead of being mislabelled "User cancelled". Genuine long prints are untouched: nothing is capped, a still-running >24h print is never treated as stale, and a real >24h run keeps its full measured duration. Re-running reconciliation is already idempotent (the archive flips toaborted, so it isn't re-selected). Existing inflated rows from before this fix are not auto-corrected — they're indistinguishable from real cancellations in the database, and a blanket cap would clobber genuine long prints; the reporter repaired his own rows by hand. Covered by tests for the multi-day reconcile, multiple stale archives per printer, a retained >24h print, and the Stats total ignoring reconciled time while still counting real runtime. - H2C prints intermittently recorded no filament and never deducted from inventory (#2582, reporter @gyrene2083) — On an H2C (firmware
01.02.00.00) filament usage sometimes wasn't deducted and the Print Log showed no filament for that print; the reporter confirmed the tell-tale detail — the failed print's archived.3mfdidn't exist to download. Filament totals, the Print Log filament column, and the weight deduction all read the sliced 3MF's data, so when that file can't be pulled off the printer the print drops to the no-3MF fallback archive and every one of them comes up empty. The download itself was the failure: the H2C is the same H2 generation and the same firmware line as the P2S, whose FTPS data channel trips a vsFTPd + TLS 1.3 session-reuse bug on Python 3.13 (#1401) — and the X2D hit the sibling handshake variant (#1638). Both were fixed by capping that model's FTP control/data channel to TLS 1.2 via the per-model FTP profile registry, but the H2C had no entry and so ran on the Python-default TLS 1.3, leaving its 3MF downloads to fail the same way (intermittently, matching the "sometimes works, sometimes doesn't" report — the session-reuse race rather than a hard handshake failure). The H2C now gets the samecap_tls_v1_2profile as the P2S/X2D (with itsO1C/O1C2SSDP codes mapped to it), so the sliced 3MF comes off the printer reliably and the slice data — filament total, Print Log filament, and the inventory deduction — is populated again. H2D is deliberately left on the default profile; it negotiates TLS 1.3 without this fault. - An unresolved AMS mapping silently dispatched a P1S print to the empty external spool (#2589, reporter @Jostxxl) — A queued P1S job with a regular AMS attached, two compatible PETG spools loaded, and nothing on the external spool holder started against the external feed and paused seconds later with a filament-runout HMS. The queue row was correct on its face —
use_ams=true— but carriedams_mapping=[-1], and Bambuddy turned that into a print with no AMS. Two faults combined. A-1was read as "external spool." The command builder's rule for "all slots are external, so dropuse_ams" testedt < 0 or t >= 254— folding the unresolved sentinel (-1) in with a genuine external selection (254/255). An explicit external print serializes as[254]; an unresolved slot serializes as[-1], and the two mean opposite things — one is "use the spool holder", the other is "we never worked out which tray." Only>= 254may now forceuse_ams=False;-1never does. The unresolved mapping was trusted instead of recomputed. The scheduler only computes a mapping when the row has none; a stored[-1]is non-empty, so it looked "already resolved" and was passed through verbatim — even though the backend had the live AMS trays and the plate's filament requirements right there and could have matched them. Dispatch now recomputes whenever the stored mapping is entirely unresolved, so a bogus[-1]self-heals against the trays actually loaded (and any pre-existing stuck row heals on the next scheduler pass); if nothing compatible is loaded it is cleared rather than sent, so the firmware reports a clear mapping error instead of quietly printing to an empty feed. Where the[-1]came from. The Print dialog builds the mapping from the selected printer's live status; if you submitted a single-printer job in the instant before that status query resolved, it matched against zero known trays and serialized every required slot as-1. The dialog now waits for the printer's AMS status before it will submit (showing a brief "Waiting for AMS status from …" notice), and the mapping hook returns no mapping rather than an all--1one while the trays are unknown — so the scheduler resolves it at dispatch. A genuine no-match with trays present still serializes-1and surfaces the mismatch as before. Tests. Backend: the command builder keepsuse_ams=truefor[-1]/[-1,-1]and a padded[-1,-1,5], still drops it for an explicit[254]; the scheduler recomputes a stored[-1], leaves a resolved (or manually-overridden) mapping untouched, and clears an unresolvable one. An existing test that asserted the old[-1] → use_ams=Falsebehaviour was corrected to the fixed contract. Frontend: the mapping hook returnsundefinedwhile status is loading, resolves to the AMS tray once it arrives (type-only match with strict colour off), and still emits-1for a real mismatch. Full backend suite and the PrintModal/mapping frontend suites green. - Pushover Emergency priority (2) was rejected by the Pushover API (#2586) — Setting a Pushover provider to priority 2 (Emergency) made every notification fail with Pushover's own error that
retryandexpireare required. Pushover mandates those two parameters for Emergency alerts —retryis how often it re-alerts (minimum 30 s) andexpireis when it stops (maximum 10800 s / 3 h) — and Bambuddy never sent them, so the message was refused before it left the app. Priority 2 now works: two new optional fields (Emergency Retry / Expire) appear on the Pushover provider only when priority is set to 2, default to a sensible 60 s / 3600 s, are clamped to Pushover's legal 30–10800 s range, and are sent only at priority 2 (Pushover ignores them at other priorities). Emergency alerts now keep re-alerting until acknowledged, as intended. - P2S RTSP timeout could leave the fan-out camera stream permanently stalled (#2580, reported and diagnosed by @ronaldheft, fix shape from PR #2581) — After an RTSP read timeout, the stream cleanup killed the stalled ffmpeg and then waited unbounded for it to be reaped. A SIGKILLed ffmpeg stuck in uninterruptible I/O on a dead RTSP socket can take arbitrarily long to exit, so the fan-out stream coroutine sat parked in that wait — in the reported case for 12 hours — while every new viewer attached to the stalled broadcaster and got no frames (snapshots and diagnostics kept working, since those open fresh connections). The post-kill wait is now bounded (2 s): on timeout the stream abandons the zombie — the orphan janitor's /proc scan reaps it on its next pass — and proceeds to its normal reconnect, so live view recovers by itself. The same unbounded wait hid in two more places, both bounded too: the camera Stop endpoint (which would hang the very request a user makes to recover a stuck stream) and the periodic orphan-cleanup janitor itself (which is the safety net that recovers stalled streams, and so can least afford to block).
- Queue edit showed the sliced-for model as the scheduler target, and a cross-model queue row could dispatch G-code to an incompatible printer (#2578, reporter @Jostxxl) — Two bugs with one root. The "Any <model>" assignment button labeled itself from the file's slice metadata while the scheduler actually used the row's
target_model, so an X1C-sliced item targeting H2D read "Any X1C" above "Scheduler will assign to first available idle H2D printer". Worse, the mismatch could be created silently: the sliced-for model loads asynchronously, and clicking "Any Model" before it arrived pre-selected the first model alphabetically — on a mixed X1C/P1S/H2D farm that's H2D — after which the model dropdown hid itself, leaving no way to see or fix the wrong target. Nothing downstream checked compatibility, so the scheduler would happily hand X1C G-code to an H2D. Now: the target model is never silently defaulted (the dropdown stays visible in model mode, pre-selected to the sliced-for model when available, and back-fills once the metadata loads); the button reflects the actual target; a warning shows when the target differs from the sliced-for model. Compatibility is enforced end-to-end with an explicit G-code interchange family table (X1/X1C/X1E/P1P/P1S interchange; everything else exact-match — files without slice metadata are never blocked): incompatible models are disabled in the dropdown, queue create/update reject a mismatch with a clear 400 (so API-created rows can't sneak in), and the scheduler holds back pre-existing mismatched rows with an actionable waiting reason instead of dispatching them — fix the target via edit and the job flows again. - Manual jog could drive an axis past its travel limit into a collision (#2579, reporter @R3play210) — Jog the bed up from Bambuddy and, instead of stopping at the travel limit, it keeps going until the nozzle hits the plate; X/Y overrun too, on every model. Instrumenting the exact bytes sent to an H2D showed Bambuddy issuing a clean, correct move at the limit —
G91/G1 Z-1.00 F600/G90, no endstop manipulation — that the printer executed straight past the stop, while the machine's own touchscreen refuses the identical motion. This is a Bambu firmware bug: the firmware does not enforce its soft endstops on G-code received over MQTT (the path every remote tool, Bambuddy included, must use), and it reports no axis position, so Bambuddy cannot know where the bed is to stop it either. It is not fixable from our side. Two things change here: (1) the jog no longer wraps moves inM211 S0/S1— the old code disabled the firmware's soft endstops globally around every jog, which also broke the touchscreen's limits until the printer was power-cycled; it now sends a bare move and never touchesM211, so the touchscreen stays protected. (2) The jog panel now shows a prominent warning that travel limits are not enforced during manual moves because of this firmware bug, so nobody trusts the control to stop at the limit. Client-side travel-limit enforcement (dead-reckoning from a home) is tracked separately as the only real mitigation. If your printer currently overruns even from its touchscreen, power-cycle it once to restore the endstops an older Bambuddy build disabled. - External spool kept its old inventory filament after the type was changed on the printer (#2575, reporter @ajbastien) — Assigning a new filament to the external spool (e.g. generic ABS in place of generic TPU) left the previous inventory spool assigned, so an ABS spool stayed mapped to TPU. The reconciliation that unlinks a stale external-spool assignment lives in
on_ams_change, but that callback only fired on changes to the regular AMS units — its change-hash never included the external spool (vt_tray/vir_slot), and the external-spool data is stored after the AMS handler runs. External-spool identity changes (type, colour, tag, or a reset to empty) now re-trigger the callback so the stale assignment is unlinked; the fill-percentage (remain) is deliberately excluded from the fingerprint so a running print doesn't fire it on every push. Follow-up: the auto-unlink now also broadcastsspool_assignment_changedfor each cleared slot — previously only the manual assign/unassign endpoints did, so an open browser kept rendering the now-unlinked spool on the slot until an unrelated refetch, which read as "the fix didn't work" even though the server state was already correct (reporter confirmed a browser refresh showed the right state all along). - Two or three users opening the UI at once exhausted the PostgreSQL connection pool immediately (#2572, reporter @Jostxxl) — Even after the session-hygiene fixes below, the reporter's 93-printer farm saturated the pool the moment a couple of clients logged in together: the log filled with
QueuePool limit of size 10 overflow 20 reached, connection timed outfrompermission_checker/is_jti_revoked/is_auth_enabled, and every one of the 30 stuck sessions wasidle in transactionwith the same last statement — theauth_enabledsettingsSELECT. Three things had regressed ondevafter an earlier configurable-pool change was reverted and never re-landed (only the route-by-route session fixes were). (a) The pool was back to a hard-coded, farm-hostile size. PostgreSQL ran onpool_size=10 + max_overflow=20(30 connections total) with no way to raise it; theDB_POOL_SIZE/DB_MAX_OVERFLOW/DB_POOL_TIMEOUT/DB_POOL_RECYCLEenv knobs and theGET /api/v1/system/db-poolgauge were gone. The PostgreSQL default is again20 + 80(100) withpool_pre_pingand a 1800spool_recycle, all env-overridable, and/system/db-poolis back (it reports resolved config + live checked-out/checked-in/overflow without itself checking out a connection, so it stays truthful under saturation). SQLite is unchanged at20 + 200. (b) Every protected request re-queriedauth_enabledfrom the database. That per-request round-trip — the exactSELECTseen on all 30 stuck sessions — is back to being cached for 30s. The cache is deliberately one-directional: only an enabled result is ever cached, so a stale read can only make a request require auth that a moment ago didn't — it can never skip a check that is now required (staleness fails closed). Toggling auth invalidates it immediately; the TTL is only a backstop for out-of-band changes. (c) Every authenticated request checked out two pooled connections, not one. The permission dependencies already hold a session, but the revoked-jticheck opened a secondasync_sessionon top of it — so a burst of concurrent logins (the SPA fires many protected endpoints at once) needed twice the connections it should.is_jti_revokednow accepts and reuses the caller's session; the two token dependencies that check the jti before they have a session open were restructured to open one first, so each authenticated request makes a single checkout. Covered by tests for the dialect-aware pool sizing + env overrides, the pool-status shape, the True-only fail-closed cache (enabled cached, disabled/unconfigured never cached, DB error propagates), and the jti check reusing a provided session versus opening its own. - The file-manager, storage, camera-snapshot and timelapse routes still held a DB connection across their FTP/camera work (#2572, reporter @Jostxxl) — After the earlier #2572 fixes the farm still bled connections over a long run — the pool crept from its normal ~14 to the full 300 across ~23 hours (with only ~20 of 93 printers powered on) and then threw
QueuePool limit … connection timed out. These were the remaining routes of the same class: each took its printer row viaDepends(get_db), whose session stays open for the whole request, and then talked FTP to the printer — a listing, a multi-MB download, a delete, a storage probe — with a browser polling the cover/snapshot tiles for every card, offline ones included, and 73 unreachable printers each burning a full FTP timeout. The printer-files endpoints (/files,/files/download,/files/gcode,/files/plates,/files/plate-thumbnail,/files/download-zip,DELETE /files,/storage), the camera snapshot endpoint (sibling of the already-fixed stream), and the timelapse scan and select endpoints now read what they need in a short session, release the connection before the FTP/camera work (expire_on_commit=Falsekeeps the loadedprinter.*columns readable), and — for timelapse, which also writes — re-open a fresh short session only to attach the downloaded file. Behaviour is unchanged; the timelapse-scan boundary is pinned by a regression test that mocks the FTP listing/download and asserts both the detached-row reads and that the attach persists through the fresh session. Completes the route-by-route half of the #2572 effort (camera stream, cover, on_print_start, timelapse scan, finish photo, notification snapshots). - Four async FTP helpers had no overall timeout, so a saturated FTP thread-pool could pin a caller — and any DB connection it held — indefinitely (#2572, reporter @Jostxxl) — FTP runs in a fixed 48-worker thread pool.
download_file_try_paths_async,download_file_bytes_async,get_storage_info_asyncanddelete_file_asyncwrapped their worker in a barerun_in_executorwith noasyncio.wait_for(unlikelist_files_async/download_file_async, which already had one). The per-socket timeout only bounds a worker once it starts; it does nothing for the time a call spends queued waiting for a free worker. On a farm where offline printers keep every worker parked on dead connects, that queue wait is unbounded — so an awaiting coroutine, and any pooled DB connection it was still holding, could wait forever. All four now cap the whole operation withasyncio.wait_for(returning the same failure sentinel on expiry, the orphaned worker's result discarded), so a backed-up FTP pool can no longer pin a caller — defence-in-depth beneath the route fixes above. - A wedged SMTP server could freeze the entire event loop during an email notification (#2572, reporter @Jostxxl) —
_send_emailransmtplibsynchronously on the event loop and constructed the connection with no timeout (smtplib then falls back to the global socket timeout, which the app never sets). A relay that accepts the TCP connection but stalls on the greeting/login/DATA left the send blocked forever — and because it ran inline, it stalled every other coroutine with it. The send now runs off the loop (asyncio.to_thread) with an explicit 30s connect timeout, andquit()moved into afinallyso a mid-send error can't leak the socket. Latent bug surfaced while auditing #2572; it presents as a stall/latency spike rather than the pool leak, but the same "blocking I/O on the loop" family. - The API didn't start serving for ~100 seconds on a large farm while it connected to printers one at a time (#2572, reporter @Jostxxl) — On the reporter's 93-printer farm port 8000 didn't respond until roughly 100 seconds after the service started. The cause was in the FastAPI lifespan:
init_printer_connectionslooped over every active printer andawaited each connection serially, and eachconnect_printerends in a fixed one-second settle wait. The MQTT connect itself is non-blocking —BambuMQTTClient.connect()only callsconnect_async()+loop_start(), so the handshake runs on a background thread — which means that one-second wait, times the fleet size, was pure serial dead air that the lifespan blocked on before the ASGI server began accepting requests. The connections are now started concurrently withasyncio.gather, so the whole step takes about a second regardless of how many printers you run, and the dashboard is reachable almost immediately. Each connection's result is also isolated (return_exceptions=True): a single unreachable printer no longer aborts the rest — or, as the old un-guarded serialawaitallowed, the entire startup. The MQTT clients still connect in the background exactly as before; only the startup wait is parallelized. - The print-start handler held a DB connection open across plate detection and the 3MF download (#2572, reporter @Jostxxl) — After farm-testing the first round of #2572 fixes the reporter still saw
idle in transactionsessions lasting minutes, and traced one toon_print_start: its last statement wasSELECT print_archives…, immediately followed in the log by the printer's ownon_print_start→Trying filenames→ FTP work. The handler opened a single database session at the top and held it to the very end of the function — across two slow I/O blocks that need no database: the optional plate-detection camera capture (a 2.5s chamber-light settle plus an FTP/RTSP grab) and, on the new-archive path, the 3MF FTP download itself (up to five remote paths per candidate filename, each with retry/backoff — the code's own comments cite worst cases of tens of minutes under FTP contention). So one pooled connection sat idle-in-transaction for the whole of both, once per starting print, and print starts cluster on a farm. The connection is now released at both boundaries: reaching either point, only readSELECTs have run on that path (every write branch returns earlier), so a commit persists nothing and simply ends the read transaction, returning the connection to the pool for the duration of the I/O; the next query re-acquires a fresh one, andexpire_on_commit=Falsekeeps the already-loadedprinter.*columns readable with no lazy load. Behaviour is unchanged. Continues the #2572 effort (camera stream, timelapse scan, finish photo, notification snapshots) to stop holding sessions across slow I/O. - The printer-cover endpoint held a DB connection open across the FTP thumbnail download (#2572, reporter @Jostxxl) — The reporter's second correlation: a transaction whose last statement was
SELECT printers…, matched in the log to the cover route (Cover: resolved plate …/Trying to download cover … (trying 4 paths)), still open more than three and a half minutes later.GET /printers/{id}/covertook its printer row viaDepends(get_db), andget_dbis ayielddependency — its session stays open for the whole request, including the cover's 3MF download (up to eight remote paths × retries with backoff, minutes under the same single-FTP-socket contention that produces the 425s). The session was used for exactly oneSELECT; everything after reads already-loadedprinter.*scalars,printer_manager, and FTP/zip — no database. The endpoint now fetches the printer in a short-lived session and releases the connection before the download (expire_on_commit=Falsekeeps the columns readable), mirroring the camera-stream fix. Pinned by a regression test that fails if aget_db-held session is ever re-added to the route. - Queue polling re-parsed every 3MF from scratch on each poll (#2573, reporter @Jostxxl) — The Queue page polls
GET /api/v1/queue/every few seconds, and for each item with aplate_idthe serializer called three separate helpers —extract_print_time_from_3mf,extract_filament_usage_from_3mf,extract_bed_type_from_3mf— each of which independently opened the item's ZIP and re-parsedMetadata/slice_info.config. With 22 queued items that is 66 ZIP-open + XML-parse operations per poll, run in the event-loop thread, repeated for every connected browser even though the files never changed. The three values now come from a single combined parse (extract_plate_metadata_from_3mf) cached by file revision — the key is(path, plate_id, mtime_ns, size), so an unchanged file is parsed at most once and a replaced or edited file transparently re-parses with no manual invalidation. The three legacy helpers still exist (other callers use them) but now delegate to the same cached parse, so usage-tracking and Spoolman paths benefit too; the queue hot path calls the combined helper once per row. The cache is a bounded (512-entry) LRU guarded by a lock so it stays small and is safe from worker threads. Listing an unchanged queue now serializes DB data and does no repeat 3MF parsing. (The reporter's broader farm-scale asks — a WebSocket-delta queue, an initial snapshot endpoint, ETag/304 support, per-row plate-request batching — are a separate queue-page redesign, not part of this fix.) - Progress-milestone and HMS-error notifications held a DB connection across the camera snapshot (#2572, reporter @Jostxxl) — Both notification paths inside
on_printer_status_change(the 25/50/75% milestone push and the new-HMS-error push) opened a database session, then captured a camera snapshot for the notification image — an up-to-15s RTSP grab — and sent the notification, all with the session held. So a pooled connection sat idle for the whole grab, per milestone/error, per printer; on a farm those fire constantly. The snapshot needs no database, so it now runs between two short sessions: one to read the printer name, then the grab with no connection held, then a fresh session for the notification send. Behaviour is unchanged; pinned by a test that fails if the snapshot ever runs while a session is open. The AMS-change notification path was left as-is for now (it holds a per-printer lock across its write and needs separate care). Continues the #2572 effort (camera stream, timelapse scan, finish photo). - Finish-photo capture held a DB connection open across the whole camera grab (#2572, reporter @Jostxxl) — When a print finishes, the background finish-photo task reads a couple of rows (the capture setting, the printer, the archive) and then runs a capture pipeline that can take tens of seconds — timelapse last-frame extraction, waiting up to 20s for the stage-22 producer, an external-camera HTTP grab, or a fresh RTSP shot. It held one database session open across that entire pipeline, so a pooled connection sat
idle in transactionfor the full capture, once per finishing print — and finishes cluster on a farm. It now reads what it needs in a short session, releases the connection, runs the capture with no session held, and re-opens a fresh short session only to append the photo to the archive. Behaviour is unchanged. Continues the #2572 effort (camera stream, timelapse scan) to stop holding sessions across slow I/O. - Timelapse scan held a DB connection open across every FTP round-trip (#2572, reporter @Jostxxl) — After a print completes,
_scan_for_timelapse_with_retriespolls the printer's FTP server for the new timelapse file (up to 4 retry attempts, plus a name-match fallback). Each attempt opened one database session and held it across the FTP directory listing and the multi-MB video download — so a pooled connection satidle in transactionfor the whole transfer, once per attempt, per completed print. When several prints finish together on a farm that adds up. The scan now reads the archive + printer in a short session, releases the connection, does the FTP list/download with no session held, and re-opens a fresh short session only to attach the downloaded file. Behaviour is unchanged; the existing scan tests already exercise the read→download→attach path. Continues the #2572 effort (after the camera-stream fix) to stop holding sessions across slow I/O; the scheduler paths were reviewed and found already bounded (single loop + capped concurrent uploads, with an explicit pre-dispatch commit) so they were left as-is. - Live camera stream held a database connection open for its entire duration (#2572, reporter @Jostxxl) — The
/camera/streamMJPEG endpoint took its printer row viaDepends(get_db), butget_dbis ayielddependency: its session isn't released until the response body finishes streaming, which for a live stream is however long the browser tab stays open — minutes to hours. On a large farm every open camera tile therefore pinned one pooled DB connectionidle in transaction, so a wall of dashboards could drain the pool on its own (a top contributor to the exhaustion in #2572). The endpoint now fetches the printer in a short-lived session and releases the connection before it starts streaming (expire_on_commit=Falsekeeps the already-loaded columns readable). Pinned by a regression test that fails if aget_db-held session is ever re-added to the route. Part of the broader effort to stop holding sessions across slow MQTT/FTP/camera/3MF work. - PostgreSQL connection-pool exhaustion on large printer farms (#2572, reporter @Jostxxl) — On a ~93-printer farm the SQLAlchemy pool (hard-coded
pool_size=10+max_overflow=20= 30 connections) was repeatedly saturated with all connectionsidle in transaction; unrelated API requests then waited out the 30-second pool timeout or failed in the auth middleware, and an unauthenticated/api/v1/printersprobe took ~25s to return 401. Three things fed the pressure: the pool was fixed and not configurable; every authenticated request re-queriedauth_enabledfrom the DB (the middleware alone opened a session per request just to probe it); and the pool was small for a farm. This change (a) makes pool sizing configurable viaDB_POOL_SIZE/DB_MAX_OVERFLOW/DB_POOL_TIMEOUT/DB_POOL_RECYCLEenv vars and raises the PostgreSQL default to20+80(100 total) withpool_pre_pingand a 1800spool_recycle; (b) caches theauth_enabledprobe for 30s — only the enabled result is ever cached, so a stale read can only ever fail closed (require auth), never open, and any toggle invalidates it immediately; and (c) adds aGET /api/v1/system/db-pooldiagnostic exposing the resolved config plus livechecked_out/checked_in/overflowgauges (read without checking out a connection, so it stays truthful under saturation). Note: connections being held across slow MQTT/FTP/camera/3MF work — the underlying reason transactions sit idle — is a deeper session-hygiene change tracked separately; this drop relieves and instruments the problem and makes the farm sizing configurable. See the PostgreSQL wiki page for large-farm tuning and the requiredmax_connectionsheadroom. - P1S camera still black on every page load, recovering only after ~20 minutes (#2521, reporter @nnimby848) — The previous round of fixes did not take, and the reporter re-tested on two daily builds to say so. The fan-out barrier added last time — a replacement stream waits for the displaced one's socket to close before dialling, so a printer that allows a single camera connection never sees two at once — was correct, and was being bypassed.
shutdown_broadcaster()popped the broadcaster out of the registry and only then awaited its teardown, so for the duration of the socket close the registry slot sat empty. A/camera/streamrequest landing in that window found nothing, minted a broadcaster with no predecessor to wait for, and dialled port 6000 immediately. The barrier only engages when the displaced broadcaster is still findable — and the one path that tears a stream down on purpose removed it first, disabling the barrier in exactly the case it was written for. A page reload fires/camera/stopand the new/camera/streamconcurrently, which is why it reproduced on essentially every load. The printer then held two connections, kept feeding the orphan, and starved the live viewer: the new socket connects (the reporter's logs showChamber image: connected) and then receives nothing until the printer's TCP keepalive reaps the dead one — his 20 minutes, to the minute. The stopped broadcaster now stays in the registry so the next viewer chains behind its socket close, which is what the barrier always intended. Pinned by a test that counts actual sockets through the real stop-then-restream race and fails with2against the old code; the existing barrier tests placed the broadcaster into the registry by hand, which is precisely why they never caught this. - Every camera page load attached two viewers and abandoned one (#2521) — Found while reproducing the above, and the reason it fired on every load rather than occasionally. The stream-token query runs whether or not authentication is enabled, and the camera page subscribes to it: the first render produced an
<img src>with no token, the token arrived, and the re-render changed the src. The browser aborts the in-flight request and issues a second one — and with auth disabled no token is required, so both reached the backend and attached to the fan-out. The reporter's HAR shows it exactly: two requests to the same stream URL, same cache-buster, one withouttoken=and one with. His backend log shows the consequence,subscribers=2, on a printer that allows one connection. The src is now rendered only once the token query has settled — one URL, one request, one viewer — and an auth-disabled install whose token endpoint fails still streams, because it never needed a token. - A viewer that left during a black stream stayed counted for 30 seconds (#2521) — Also found on the way. A subscriber only checked whether its client was still connected after it had yielded a frame, or when a 30-second idle timeout fired. So a browser that walked away while the stream was producing nothing — the exact situation above — went on being counted as an attached viewer for up to half a minute. That matters beyond tidiness:
/camera/stopconsults the subscriber count to decide whether to tear the upstream down, so a phantom viewer could make it skip the teardown entirely and leave the socket open. Disconnects are now noticed within a second even when no frames are flowing. - "Please login." when importing from MakerWorld — while Bambuddy said you were connected to Bambu Cloud — An expired Bambu Cloud token was indistinguishable from a working one, so the UI reported "Connected as ..." indefinitely while every cloud call was being rejected. The toast you got was Bambu Lab's own words, forwarded verbatim: their 401 body is
{"code":4,"error":"Please login.","message":""}, and we passed theerrorfield straight through — which read as Bambuddy telling you to log in, next to an indicator saying you already were. The status was never real.set_token()stampedtoken_expiry = now + 30 daysevery time a stored token was loaded from the database — re-derived from the current moment, for a token of entirely unknown age — andis_authenticatedwas "we have a string, and we're not past that expiry". The expiry reset on every request, so the check could never fail. It was a string-presence test wearing an expiry costume, and/cloud/statusansweredtruefor as long as any token existed. Bambu's access token is opaque (no readable claims), Bambu's login response carries no expiry, and Bambuddy discards therefreshTokenit is handed, so nothing else in the system knew either. When a token lapsed — Bambu's own comment in our code says they last around three months — every cloud feature died at once (MakerWorld imports, cloud profiles, slicer presets, firmware checks) with no signal anywhere that a re-login was needed. Bambu is now the authority. No expiry is invented./cloud/statusasks Bambu whether the token is still accepted, cached for five minutes so the several components polling it don't each pay a round-trip, and any 401 from any authenticated cloud call durably records the credential as dead — so the whole app agrees at once instead of each feature failing separately. An unreachable Bambu, a 5xx, or a Cloudflare challenge is treated as unknown, never as expired: an outage must not sign a working session out. The Profiles page now explains why the login form is back, MakerWorld says the sign-in expired rather than that one is required, and its import buttons stop pretending they can download. The user-facing message names the Profiles page, where the Bambu Cloud sign-in actually lives — the old fallback text pointed at "Settings → Bambu Cloud", which does not exist. - Importing from MakerWorld failed on Windows with a certificate error (#2562) — Paste a MakerWorld URL, click Save, and the import dies with
S3 download failed: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate. Only native Windows installs are affected; Docker never sees it. The import walks several hosts, and the failure is at the last hop: Bambu Cloud answers the download request with an AWS presigned URL, and that one URL is fetched withurllibrather than httpx, on purpose — S3 signs the exact query-string bytes, and httpx re-encodes them into aSignatureDoesNotMatch. What that swap quietly also changed was the trust store. httpx — every other network call in Bambuddy, including theapi.bambulab.comcalls that succeed immediately before this one — verifies against the bundledcertifiCA bundle.urllibverifies against the operating system's store, and on Windows the two disagree: Python'sssl.load_default_certs()only enumerates the roots already cached in the Windows ROOT store, which Windows fills in lazily through CryptoAPI's auto-update — a mechanism Python never triggers. On a machine where the Amazon root signing the S3 chain has not been cached yet, verification fails with exactly the error above. Linux images ship a completeca-certificatesbundle, so the OS store and certifi agree and the bug is invisible there. The S3 hop now verifies against certifi too, so it trusts precisely what the rest of the app already trusts. Verification itself is untouched — the certificate is still checked and the hostname still matched; the fix changes where the CA list comes from, not whether TLS is enforced. The URL still reaches the transport byte-for-byte, so the S3 signature is unaffected, and the no-redirect guard that keeps the download-host allowlist meaningful is unchanged.certifiis now an explicit requirement rather than one inherited from httpx, so a future httpx release cannot drop it out from under this import. - Prints on a multi-printer farm started one by one, up to an hour apart (#2555, reporter @Maxtrim3D) — Start a batch across several printers and they trickle out one at a time; the more printers, the worse it gets. Not a misconfiguration, and nothing in the wiki could have helped: the scheduler awaited each dispatch inline in its selection loop, and a dispatch includes the FTP upload. So every printer queued behind every other printer's transfer, even though they are entirely independent machines. The arithmetic is the whole bug report. A Bambu printer's FTP server sustains around 150 KB/s — its own SD-card write is the bottleneck, not the network — so the reporter's 41 MB
.3mftook 254 seconds per printer, straight from his logs (40978500 bytes in 254.1s, 157 KB/s). Nineteen printers in series is roughly 80 minutes before the last one starts, which is exactly the "up to 1 hour" he reported, and exactly why it got worse the more printers he selected — the delay is linear in fleet size. The logs show the next upload beginning 131 ms after the previous one finished, back to back, forever. Uploads to different printers now run concurrently, capped by a new Settings → Workflow → Queue & Dispatch → Concurrent Uploads value (default 4, up to 16; set it to 1 for the old strictly-serial behaviour if your network or host cannot take parallel transfers). Selection is unchanged and still sequential — only the transfers overlap — so every existing gate (busy printers, plate-clear, filament deficit, shortest-job-first, staggered start) behaves exactly as before, and a queue pass still finishes all of its uploads before the next one begins, which is what stops the same still-pendingrow being dispatched twice. FTP work also moves off asyncio's shared default executor onto its own pool: that executor is sizedmin(32, cpu_count + 4)— six threads on a 2-core NAS — and is shared with everything else in the app, so parallel uploads would have parked one thread each, for minutes at a time, and starved unrelated work. - A printer that accepted a file but never started printing was retried forever (#2555) — Surfaced by the same reporter: "I have a printer who, since the morning, still not launch." When a printer takes the file (its
subtask_idadvances) but never actually begins, the start-watchdog waits 270 seconds, reverts the queue item topending, and the next pass re-uploads the entire file and waits it out again — with no attempt limit. For a genuinely wedged printer that loop never terminates, and on a farm each lap also consumes an upload slot the other printers are queueing for, so one stuck machine dragged out everybody else's start times. Retrying is right; retrying forever is not. Attempts are now counted on the queue item: the transient causes the watchdog already recovers from (a publish lost on a half-broken MQTT session is fixed by the forced reconnect on the very next try) still get their retries, but after three the item is failed with a message pointing at the printer — check its screen for a prompt or error, and check the SD card — rather than being handed back to the queue a fourth time. - A queued library print with no readable print time crashed the dispatch — and took the rest of that queue pass down with it (#2555) — Found while reviewing the above. Starting a print from a library file read
library_file.print_time_seconds, a columnLibraryFiledoes not have (its print time lives in the file's parsed metadata). It only fired when the archive carried no print time of its own — a plain.gcode, or a 3MF the parser could not read — and it fired after the job had already been sent to the printer, so the print itself ran but the "print started" notification was lost. Worse, the error unwound the whole queue pass: every other printer still waiting to be dispatched on that tick silently missed its turn and had to wait for the next one. It now uses the print time the queue item already caches. The concurrent-dispatch change above independently contains this class of failure — one printer's dispatch blowing up can no longer cancel its siblings' in-flight uploads. - A print mapped to a different filament than it was sliced for was logged under the sliced material, not the one actually used (#2563, reporter @alexfilimon) — Slice a model for Bambu PLA Basic, open Filament Mapping in the Print dialog, and — because no PLA was loaded — hand-pick the only loaded PETG slot. The printer prints from PETG, the PETG spool is correctly debited, but the Archive card, the Print Log and the material statistics all still call the run PLA. So "filament used", the one label that should describe what left the spool, described what the slicer asked for instead. The archive's
filament_typeis stamped once from the 3MF at creation and never revisited; the Print Log copies it verbatim at completion and the stats group on it. The material Bambuddy actually consumed was known all along — usage tracking resolves every used slot to the spool that fed it and already carries that spool'smaterial— it just wasn't being written back. This is the exact problem that was solved for filament colour a while ago (#1494): once usage tracking has matched every used slot to an inventory spool, the spool's curated colour replaces the slicer's, so an archive printed from a#000000spool stops showing the slicer's near-black. Material now does the same. When every slot with non-zero usage resolves to a spool that declares a material, the archive'sfilament_typeis rewritten from those spools — slot-ordered, de-duplicated, comma-joined exactly like the colour and the original type — and because that rewrite is committed before the Print Log entry is written, the corrected material flows through to the archive card, the Print Log and the stats with no further work. All-or-nothing, deliberately, mirroring the colour path: if even one used slot can't be resolved to a spool with a material, nothing is rewritten, so a partial match can never drop a slot's type from the archive or the material graph. A run whose mapping matched the slice is a no-op (the rewrite equals what's already there). Both inventory backends, same drop. The built-in Spool inventory does it from the matched spools'material; Spoolman does it from the resolved Spoolman spool'sfilament.material, captured at the same point the spool is already fetched for its colour, so no extra Spoolman round-trips. The remain%-delta fallback (no-3MF "Untitled" prints) intentionally sits it out in both backends, exactly as it does for colour — those prints have no 3MF slot to attribute a material to. Tests. 7 on the internal helper (the reporter's PLA-slice-to-PETG-spool case; slot-ordered de-dup across a multi-material print; the all-or-nothing gate leaving a partially-matched print untouched; a zero-usage slot needing no spool; no-used-slots and slot_id-less fallback results both declining to rewrite; a blank material not counting as a match). 3 on the Spoolman archive rewrite against a real DB session (a PETG spool overwrites a PLA slice; a partial match leavesPLA,PLAuntouched; an empty material map is a no-op). Existing usage-tracker and Spoolman suites unchanged and green. - Every job on a busy farm waited up to 30 seconds after a printer freed up before it was sent (#2555, reporter @Maxtrim3D) — With the parallel-upload fix in, the reporter still saw prints take "several long minutes" to leave the queue, sometimes going out together and sometimes in dribs. The scheduler's main loop did its work and then slept a fixed 30 seconds before looking again, unconditionally. That interval is dead air: a printer that finished a job one second after a pass ended sat idle for the next 29 before its follow-on print was even considered, and a batch fanning out across a fleet — where printers free up a few seconds apart as their current jobs end — dispatched in 30-second steps regardless of how fast the machines were actually becoming available. On nineteen printers that is minutes of nobody-is-uploading time stacked on top of the transfers. The loop now re-checks within a few seconds whenever the previous pass actually dispatched something, and only falls back to the 30-second idle sleep when a pass sent nothing. So a draining batch keeps moving at the speed the printers free up, not at the speed of a fixed timer. This cannot become a busy-loop: the fast tick fires only after a productive pass, and a pass is productive only while there is ready work to send — the moment the remaining items are all behind printers that are genuinely busy printing (or behind a wedged head-of-line job holding its printer in the post-dispatch cooldown), the pass dispatches nothing and the loop reverts to the slow interval. Selection, the concurrency cap, and the finish-all-uploads-before-the-next-pass invariant are all untouched; only the gap between passes shrinks when shrinking it helps. Tests. 2 new cases: a pass that dispatches three items reports that it did (so the caller re-ticks fast), and an empty queue reports that it did not (so it sleeps normally). The existing concurrent-dispatch suite — parallel fan-out, the cap, the serial escape hatch, one-failure-doesn't-cancel-siblings, and the uploads-finish-before-return invariant — passes unchanged against the new return value.
- Debug logging was unusable on a large fleet, and the support bundle only shipped a fraction of what was on disk (#2555) — We asked the reporter to turn on debug logging and send a support bundle. The bundle came back holding 4 minutes 49 seconds of history — barely one upload — for a problem that takes an hour to unfold. Two causes, both fixed. The state dumps in the MQTT push_status handler fired whenever their field was present in a frame, and a full frame carries every field, so they fired on every frame regardless of whether anything had changed; several said "updated" or "when X changes" in their own comment while doing nothing of the sort. On one printer that is ~1.5 lines/s and invisible. On nineteen it is ~100 lines/s: 27,727 of the bundle's 29,830 lines were these dumps, and they rolled the 5 MB log over in under five minutes. They now log transitions only — every change is still recorded, the steady-state repetition is not. Separately, the bundle shipped only the live
bambuddy.logand ignored the three rotated backups sitting next to it, even though its own byte budget was four times larger than the file it was reading; it now spans the rotation, oldest first, spending the budget on the most recent history. - Filament Override vanished for a multi-plate selection in Any [model] mode — but only on the second visit (#2552, reporter @bondjw07) — Open a sliced multi-plate
.gcode.3mf, pick Any [model], tick two plates, and the whole Filament Override section is gone. Tick one plate and it comes back. The reporter tied it to having queued or printed the file before, which is the real clue, but not the cause: what actually mattered was that the dialog had been opened once already, so the plates data was still in the cache. The filament requirements are fetched under a key that carries the selected plate, and that key isnullas soon as two plates are ticked. On the first open the plates are not yet known, so for one render the modal cannot tell it is a multi-plate file and fetches the requirements for the whole file — the union of every plate's filaments — which the override panel then rendered from. On the next open the plates are already cached, the modal knows it is multi-plate from the first render, the whole-file fetch therefore never happens, and the panel had nothing to render. So the section's visibility was decided by a cache race, and the case that "worked" was showing you filaments from plates you had not selected. Both halves are now wrong-free: a multi-plate selection in model mode renders one Filament Override — Plate N panel per selected plate, each fetched for that plate and listing only the slots that plate actually prints, identical on a cold and a warm cache. A slot's chosen filament and its Force color match tick are shared across plates that print that slot — slot ids are global to the file, so slot 3 is the same filament wherever it appears — and each queued plate is sent only the overrides for its own slots, so a colour forced for plate 2 no longer holds plate 1 back (the API narrows them per plate as of #2551, and the modal no longer sends them wide in the first place). Measured on the old code: warm cache, two plates → zero override panels; cold cache → one panel listing both plates' filaments. Now: two panels, one slot each, either way. Four further holes in the same per-plate machinery closed while reviewing it: a manual tray pick on one plate survived a change of printer, and a global tray id names a different spool on a different machine — so the job went out on a tray nobody chose; a plate whose filaments could not be read (or had simply not loaded yet) was indistinguishable from a plate needing none, and was queued with no mapping and no forced colours, to print in whatever happened to be loaded — the Print button now waits for every selected plate to answer and says which one could not be read; the "not enough filament left" check still weighed the whole file's filaments against a mapping the plates no longer use, so it either failed to warn at all or warned about trays the print would not touch — it now follows what each plate actually dispatches, and sums the demand per tray, because 60 g left does not cover two plates of 40 g even though it covers either one of them; and the per-printer tray editor still appeared for a multi-plate fan-out, collecting tray choices that were then discarded. - Queueing several plates of one file mapped them all through the first plate's filaments — and hid the panel that would have shown you (#2551, reporter @bondjw07) — Select one plate and the Filament Mapping panel appears; select a second and it vanishes, and in Any [model] mode it never appears at all. Both were deliberate, and one of them was covering a wrong-tray dispatch. Why the panel hid. It maps one set of 3MF slots onto one printer's AMS trays, so it needed a single plate and a single printer;
selectedPlates.size <= 1hid it the moment you ticked a second plate. In model mode there is no printer selected, so there are no trays to map onto — that one is legitimate, and the scheduler computes the mapping per plate when it picks the printer. What the hidden panel was hiding. The modal kept posting anams_mappinganyway. With two plates selected the modal has no single plate to ask about, so it falls back to the whole file's filament list — the union of every plate — and matched against that. Tray assignment is stateful: a tray claimed by one slot is not offered to the next. So for a file where plate 1 prints red on slot 1 and plate 2 prints red on slot 2, slot 1 took the only red spool and slot 2 fell through to a type-only match on black — and that one mapping,[red, black], was sent with both plates. The scheduler uses a stored mapping verbatim and only computes its own when the item has none, so plate 2 printed in the wrong colour, decided by a panel the user was never shown. Measured, not deduced: driving the old modal with a real cache postsams_mapping: [0, 1]for both plates. (It reproduces only with a realistic React Query cache — the test harness'sgcTime: 0evicts the union and makes the modal look innocent, which is why this hid for so long.) Now each plate maps itself. Select several plates on one printer and you get one mapping panel per plate, named after it, each showing and mapping only the slots its own plate prints, each with its own tray overrides — pin plate 2's red to a different spool and plate 1 is untouched. Each queue item carries its own plate's mapping. Fanning several plates across several printers would be a panel per plate per printer; those items are queued with no mapping instead, and the scheduler maps each plate against the printer it actually dispatches to, which it already does correctly. One matcher, not three. The tray-matching logic existed twice (once in the hook, once incomputeAmsMapping) and this needed a third caller, so it is now extracted once and both paths delegate to it — the per-plate panel and the per-printer fan-out cannot drift apart. Its 62 existing tests pass against the extraction unchanged. Tests. 3 on the matcher, pinning the exact divergence: each plate alone maps to the red tray, the union starves the second slot onto black, and a manual override on one plate does not leak into another. 4 on the modal: one panel per selected plate; each plate posts the mapping for its own slots ([0]and[-1, 0], not the union's[0, 1]); a multi-printer fan-out posts none; a model-assigned job posts none. Mutation-verified against a production-like cache — the per-plate test fails with exactly the old[0, 1], and removing the multi-printer guard leaks printer 1's trays onto printer 2. - Queueing several plates of one file with Force color match made every plate wait for every colour (#2551, reporter @bondjw07) — A sliced multi-plate
.gcode.3mf, each plate a single different PLA colour, queued to Any X1C with Force color match on. A printer with Army Blue loaded and idle should take the Army Blue plate. Instead every plate sat at Waiting onPLA (Army Blue), PLA (Ash Grey), PLA (Sunshine Yellow)— the colours of all the plates. Queue the same plates one at a time and it works, which is the tell. One override list, handed to every plate. The print dialog only tracks a selected plate when exactly one is selected; pick several and it asks the backend for the filaments of the whole file, which is the union across all plates. It builds its override list from that union — correctly, because the user does need to tick each colour once — and then posts that same list with each plate's queue item. Aforce_color_matchentry means "do not dispatch until this printer has this exact colour loaded", and the scheduler enforces all of them, so each single-colour plate demanded the whole batch's palette. The reporter's own guess in the issue was exactly right. The API is what fixes it. The overrides are now narrowed to the slots the item's plate actually prints, at write time, on both create and edit — the backend is where the 3MF is, so this holds for every writer of the queue and not just the one dialog. Nothing changes for a single-plate job or for a whole-file job, where the union is the requirement. A second, quieter version of the same bug. Override types are merged into the item'srequired_filament_types, which is the gate that runs before colours are even considered. A shared list therefore also widened that gate: queue a PLA plate and a PETG plate together and the PLA one would refuse every printer that didn't also have PETG loaded, with no mention of colour anywhere in the reason. Narrowing the overrides closes that too. Fails strict, never silent. When the plate's slots can't be established — corrupt 3MF, source file gone — the overrides are kept whole rather than dropped. An item waiting on a colour it doesn't need is visible and fixable in ten seconds; an item that silently lost its forced colour prints in the wrong filament. The plates already in your queue are repaired on upgrade. Fixing the write path alone would have left every item queued before this release sitting exactly where it is — stuck, with a waiting reason that explains nothing — until the user worked out for himself that deleting and re-adding them was the cure. A startup migration re-scopes the pending items instead. Items that are already printing or done are left untouched: their overrides are a record of what they dispatched with, not an instruction. Tests. 6 cases on the API (each of three plates keeps only its own colour and its own slot id; a whole-file job still keeps all three; an unreadable 3MF keeps all three; a PLA plate's required types stay PLA when a PETG plate is queued alongside it; editing an item narrows its overrides too; moving an item to another plate re-scopes it). 5 on the repair (three stuck items each come back to their own colour; a second boot is a no-op; a printing item is not rewritten; a whole-file item keeps all three; a missing source file strips nothing). Mutation-verified — six of the eleven fail against the old code. The migration was run against a real PostgreSQL 16 as well as SQLite, twice over, to confirm it is dialect-neutral and idempotent. - A project's tags vanished from the edit dialog when you opened it from the projects list — and its priority was quietly reset when you saved (#2536, reporter @fireboyff) — Editing a project from the Projects list showed an empty tags field; opening the same project first and editing it from inside showed the tags correctly. One dialog, two callers.
ProjectModalis shared: the detail page hands it a full project, the list hands it a list item. The list endpoint's payload never carriedtags,due_dateorpriority, so from the list the dialog seeded those three fields fromundefinedand rendered them blank. It compiled because the component read them through a cast (project as ProjectListItem & { tags?: string }), which asserts a field the type does not have — so TypeScript never pointed out that the value was always missing. The fields are now onProjectListResponseand onProjectListItem, the casts are gone, and the compiler enforces the two shapes agreeing from here on. The part nobody reported. The dialog does not send tags when the field is empty, so the tags themselves survived — they were only invisible. Priority is not so lucky: it is always sent, defaulting tonormal. So editing a high or urgent project from the list silently demoted it, and the reporter would have had no reason to connect that to the empty field he did see. Fixing the payload fixes both, since the dialog now receives the real priority to send back. Clearing a tag list also never worked, from either view. An emptied field was sent asundefined, which drops the key from the request, and the backend only applied values that were not null — so the old tags came straight back. Tags and due date now behave like budget and URL already did: sent as null, cleared explicitly, and an omitted key still means "leave it alone". Tests. 4 backend cases (the list and the template list both carry the fields the dialog renders; a partial update does not disturb a stored priority or tags; an explicit null clears tags and due date) — mutation-verified, three of them fail against the old payload. 3 frontend cases pin the dialog: it prefills all three from a list item, it round-trips a storedhighinstead of submitting its default, and clearing the tags field sends null. The templates list was missingtarget_parts_counttoo, which the same dialog edits; that is fixed in passing. - Scheduled backups to a NAS failed with "Read-only file system" — and our own systemd unit was the reason (#2544, reporter @pwostran) — Nightly backups to a mounted NAS share had run since May and then stopped, failing every night with
[Errno 30] Read-only file system. The reporter checked the folder permissions, which were correct: his mount isgid=backup,dir_mode=0775, the service user is inbackup, and his own shell writes to the share fine. Errno 30 is EROFS, and EROFS is not a permission error — a permission problem is errno 13. EROFS means the filesystem itself refused the write, and the filesystem refused it because we told it to. Bambuddy's systemd unit shipsProtectSystem=strict, which mounts the entire filesystem read-only inside the service's own mount namespace and carves back out onlyReadWritePaths=<install> <data> <logs>. A NAS share is not one of those three. Reads still work — which is why the UI happily listed his existing backups from the share while being unable to create a new one — and the operator's shell is outside the namespace entirely, so every check he could think to run said the directory was fine. How a working install broke. Both installers write/etc/systemd/system/bambuddy.servicewholesale, so anyReadWritePathsan operator had added by hand disappeared on the next install, along with their backups. That is now fixed at the source: the installers back the old unit up (.bak-<timestamp>) and carry the operator's extra writable paths forward into the new one, reporting which ones they kept. The unit template also documents the carve-out, since the next person to read it has to be able to work out why a directory they can write to is read-only for the service. The failure is no longer silent, or cryptic. The output directory is now probed with a real write when you save it and when the backup card loads, so a directory Bambuddy cannot write to is caught there and then rather than at 03:00 for a week. When the probe fails, the card names the actual cause and hands over the exact fix with the operator's own path already in it —sudo systemctl edit bambuddy→[Service]→ReadWritePaths=/mnt/nasbackup— instead of quoting an errno. A failed backup run reports the same diagnosis rather than the raw OSError. EROFS outside systemd, permission-denied, out-of-space, not-a-directory and missing are told apart and worded accordingly, in all 11 locales. A Docker trap caught on the way past. A backup path inside the container that was never bind-mounted from the host is writable — the write lands in the container's ephemeral layer and vanishes on the nextcompose up. A backup that silently goes nowhere is the one failure mode a backup feature must not have, so the probe compares the directory's device against the container root and warns when they match, with the compose snippet that mounts it properly. Tests. 15 backend cases: EROFS under systemd is diagnosed as the sandbox and yields a copy-pasteable drop-in; EROFS outside systemd does not blame a unit that doesn't exist; EACCES stays a permission problem; the unit name is read from the cgroup (plain, templated, and the fallback when there's no.servicein it); the probe leaves no file behind in the backup list; a container-layer path is flagged while a mounted volume is not; a failed run surfaces the diagnosis and not the errno; and four pin the installers, so a reinstall can never again drop a writable path or overwrite a unit without a backup. Verified against a real read-only mount, not a mocked one — the classifier was run against an actualmount -o rotmpfs and returned the reporter's exact errno with the right remedy. 4 frontend cases on the banner. - Docker never shut down gracefully — every stop, restart and update was a SIGKILL —
CMD ["sh", "-c", "uvicorn ..."]left the shell as PID 1 with uvicorn as its child, and dash does not forward signals. Sodocker stopSIGTERMed the shell and uvicorn never heard about it. Measured on the shipped image: the stop ran the full 10-second grace period, the container exited 137 (SIGKILL), and the log contained no "Shutting down" line at all — it simply stopped dead afterUvicorn running on .... That means the entire shutdown path had never once executed in Docker: no SQLite WAL checkpoint, no MQTT disconnect (the broker saw an ungraceful drop every time), no virtual-printer teardown, no printer disconnect, noengine.dispose(). Not "when a camera is streaming" — always, on everydocker stop,docker restart,compose downand image update. The fix is one word:CMD ["sh", "-c", "exec uvicorn ..."]. Withexec, uvicorn is PID 1 and receives the signal. Verified on a rebuilt image: PID 1 is nowuvicorn,docker stopcompletes in 1 second with exit code 0, and the log showsShutting down→WAL checkpoint completed→Application shutdown complete. systemctl restartcould hang for 90 seconds and end in SIGKILL — with a camera tile open, stopping Bambuddy would sit atWaiting for connections to close.until systemd gave up and killed it. Uvicorn'stimeout_graceful_shutdowndefaults toNone, i.e. wait forever for in-flight requests, and an MJPEG camera stream is a response that never completes —httptools's connectionshutdown()only flipskeep_alive = Falseon an in-flight cycle, it never closes the transport. So a single open stream pinned the process. Worse, the ordering is inverted: uvicorn only fires the lifespan shutdown — the code that would tear those streams down — after the connections drain, so the cleanup that would unblock the wait was itself blocked by the wait. Every launcher now passes--timeout-graceful-shutdown 5: the Dockerfile, the shippeddeploy/bambuddy.service, the systemd unit and launchd plist emitted byinstall/install.sh, the SpoolBuddy installer's unit (a kiosk parked on the printers page holds exactly such a stream open, so this bit it on every reboot), and the Windows NSSM registration. On timeout uvicorn cancels the request tasks and the camera generators unwind cleanly onCancelledError— a path they already handled.TimeoutStopSecis raised to 30s on the systemd units as a backstop rather than the mechanism, andstop_grace_period: 30sadded to the compose file so a slow teardown on a Pi isn't clipped. On Windows, NSSM's stop sequence was also force-killing uvicorn mid-teardown: its defaultAppStopMethodConsoleis 1500 ms, far less than uvicorn needs, so that is raised to 15s and the useless WM_CLOSE / thread-message stages (uvicorn is a console app with no window and no message loop) are skipped. Tests. 9 cases pinning every launcher — that the Dockerfileexecs, that each of the six launch points carries the timeout flag, that the systemd stop timeouts leave room for the teardown, and that NSSM waits long enough for the Ctrl-C. None of this shows up in a functional test: the app is perfectly healthy right up until you ask it to stop.- Energy Summary stuck at zero for Yesterday and Total on REST smart plugs — and the Statistics energy figure with it (#2539, reporter @R3play210) — A Shelly Plug S Gen3 wired up over the REST integration showed live power and a Today figure that climbed, but Yesterday and Total never moved off zero, through five days of printing. The bug.
RESTSmartPlugService.get_energy()returned a dict with two keys,powerandtoday. It never setyesterdayortotalat all, soSmartPlugEnergydefaulted them to null and the summary card summed nothing. Tasmota returns all three; Home Assistant returns two; REST returned one. The number that looked right was also wrong. A Shelly has no notion of "today" — its only energy figure isaenergy.total, a lifetime counter in watt-hours that climbs forever and never resets. Bambuddy had a single energy field, so the reporter put the lifetime counter in it, and line 230 filed it undertoday. It looked correct because it grows; it just never dropped back to zero at midnight. The one figure he trusted was the least trustworthy of the four. It broke more than the card. Withtotalnever populated, the hourly snapshot recorder skipped the plug outright (its own comment said so: "REST plugs that only expose today can't be used for cumulative snapshots"),_sum_live_plug_totals()summed zero, and since the reporter'senergy_tracking_modeistotal, the Statistics page's energy figure was zero too — he simply hadn't got to it yet. The fix. A REST plug now says which counter it has:rest_energy_pathstill means "energy used today", and a newrest_energy_total_pathmeans "lifetime counter that never resets". A Shelly has only the latter; a Tasmota behind a REST bridge has both; both are read from one HTTP fetch when they share a URL. Then, because the snapshot table already records that lifetime counter hourly, Today and Yesterday are derived from it: today = the counter now minus its value at the last local midnight, yesterday = that midnight's value minus the one before. So a Shelly gets all four numbers with no new device capability — and Home Assistant's permanently-null Yesterday is fixed for free. Today appears after the first midnight the install lives through, Yesterday after the second; a counter that goes backwards (factory reset zeroesaenergy.total) reports nothing rather than a negative. Local midnight, not UTC. WithTZ=Europe/Berlina UTC boundary would roll Today over at 02:00 wall-clock. The snapshot loop now ticks on the local hour instead of every 3600s from boot, so a reading lands exactly on the day boundary — including in the half-hour-offset zones (India, Nepal) where local midnight isn't on a UTC hour at all. Previously the last snapshot before midnight could be up to an hour early, and an hour of a printer's draw is real watt-hours to lose off the day. Collateral: the whole smart-plug subsystem was broken on Postgres. EveryDateTimecolumn in the smart-plug tables is naive and holds UTC, but the code wrote aware datetimes into them. SQLite tolerates that — its bind processor reads the fields and drops the offset — which is why it went unnoticed. asyncpg does not: it raisesDataError: invalid input for query argument. So on Postgres every energy-snapshot capture raised (silently, inside the loop'sexcept), leaving the snapshot table empty and the date-filtered energy stat permanently zero, and every plug status poll raised onlast_checked. Postgres is what Bambuddy recommends for multi-printer installs, so this was not a corner. All smart-plug timestamps are now naive UTC via a sharedutcnow_naive()/to_naive_utc(), and the snapshot-delta query normalises its bounds the same way. Tests. 8 cases on the derivation (today and yesterday from the counter; yesterday absent until two midnights have passed; nothing derivable before the first; a counter reset reports nothing rather than a negative; another plug's snapshots are not borrowed; a device-reported figure is never overwritten by our arithmetic). 4 on the REST driver, using the reporter's ownSwitch.GetStatuspayload (the lifetime counter lands intotaland not intoday; a plug reporting both keeps them apart; a total path alone is enough to read energy at all; both counters share one HTTP fetch). 4 more pin the Postgres-unsafe datetime — mutation-verified: reintroducing the aware timestamp fails the guard. Migration applied and re-applied against a real Postgres to confirm it is idempotent and defaults to NULL. Existing REST users: if your Energy JSON Path points at a cumulative counter (anything from a Shelly does), move it to the new Energy JSON Path (lifetime) field — the form and the wiki now say which field wants which counter.
Added
- Russian (Русский) UI translation (#2608, contributor @pterodaktil02) — Bambuddy's interface is now fully available in Russian, bringing the total to 11 languages. Pick it under Settings → General → Language. The translation covers the whole UI — printer controls and statuses, build plate and bed, filament, and AMS — with context-appropriate terminology throughout, and preserves every interpolation placeholder so counts, names, and progress values render correctly.
- "Slice as designed" — keep a MakerWorld author's own settings when you slice server-side (#2611, reporter @kpp39) — When you slice a project 3MF through Bambuddy, the SliceModal makes you pick a printer / process / filament triplet, and the slicer applies those with
--load-settings— which overrides whatever the designer baked into the file'sMetadata/project_settings.config. So a MakerWorld model set up for 5 walls came out at the picked profile's 2, and the reporter's own re-posted files lost their tweaks too. That override is correct for the flow's main job — re-slicing someone else's design for your printer and AMS, especially across models (an H2D design onto an X1C) needs the bed size and filaments swapped — but it left no way to say "just slice it the way the author set it up." What's new. When the source 3MF carries embedded settings and the picked printer matches the design's target model, the modal offers a Use the file's built-in settings checkbox. Tick it and Bambuddy slices with no--load-settingsoverride, so the designer's walls / infill / filament choices drive the result; all four preset controls (printer, process, filament, bed type) grey out to show they're bypassed — the printer included, since it's unused on this path and changing it would only pull you off the design's target and hide the toggle again. The printer-match gate is deliberate. Honouring embedded settings only makes sense when your printer is the design's printer — applying them across models would drop the object on the wrong bed, which is the whole reason the profile path exists — so the toggle simply isn't offered otherwise, and there's no cross-printer re-targeting on this path. Filament comes from the file too, not your AMS picks; the hint says so. Under the hood this reuses the existing embedded-settings slice path (previously only a crash fallback) as a first-class, user-selectable mode; the response already flaggedused_embedded_settings. Not in scope: merging a picked filament over the designer's other settings — that needs per-key precedence and is a separate future enhancement. Scope. One backend request flag + one branch, one gated frontend checkbox. No DB migration, no new permission, no new setting. Two new i18n keys (slice.useEmbedded,slice.useEmbeddedHint) translated in all 11 locales. - A paused AMS runout now names the physical slot the printer is actually waiting for (#2587, reporter @Jostxxl) — When a spool runs out mid-print, Bambuddy showed the firmware's generic HMS text — "insert a new filament into the same AMS slot" — which is exactly wrong when AMS Filament Backup is on: the firmware won't re-accept the depleted slot and advances to the next compatible one, so "the same slot" sends the operator to the wrong place. On the reporter's farm this meant reinserting into Slot 2 (where it ran out) did nothing, and the print only resumed after moving the spool to Slot 3 — with no on-screen hint that Slot 3 was what the printer wanted. Root cause. The printer's AMS payload carries
tray_tar(the slot the paused print now expects) andtray_pre(the slot that ran out) right next totray_now, but Bambuddy parsedtray_nowonly and dropped the other two at ingest, so "which slot does the print expect" never reached the API or the UI. What changed.tray_tar/tray_preare now captured on printer state and, while the print is paused, resolved to global tray IDs and surfaced on the status payload (both the REST poll and the live WebSocket push) asexpected_tray/previous_tray. The AMS graphic highlights the expected slot with a pulsing amber ring (and a down-arrow badge) and marks the ran-out slot in red, and the HMS error is re-described to name them directly — e.g. "Filament ran out in AMS-A · Slot 2. The printer is now waiting for compatible filament in AMS-A · Slot 3. Insert a spool into AMS-A · Slot 3, then select Retry." Honest when it can't tell. On a single regular AMS the reported slot is already the global ID; on multi-AMS it's a local slot that's resolved against the print's snow-encoded mapping field, and AMS-HT IDs (128–135) pass through. When the slot can't be resolved unambiguously (multi-AMS with no usable mapping), the graphic highlights nothing and the message says so — "Bambuddy could not determine which slot the printer now expects — check the printer screen" — rather than pointing at a guess. User AMS friendly-names are honored in the labels. Scope. Guidance is populated only while paused, so a healthy print's normal target churn never highlights a slot or spams the log. Backend resolver, the ingest parse, and the modal re-description are covered by new unit/component tests; the runout copy is translated in all 11 locales. - The sponsor surfaces now ask a print farm a different question than they ask a hobbyist — Since the in-app sponsor banner and milestone toast shipped in v0.2.4.8, both have made exactly one ask, to everyone: chip in a few dollars to keep Bambuddy independent. That ask works — new sponsorships went from 0.40/day to 1.40/day in the fifteen days after the release, and clicks through to GitHub Sponsors rose 4.3x on a falling web traffic base. But it is the wrong ask for part of the audience. Someone running twelve printers as a business does not want to donate $5; they want a support contract, an invoice, and somebody accountable when the line stops. They were being shown a donation button and, unsurprisingly, ignoring it. What changed. At 5 or more configured printers the Settings → General banner and the milestone toast both make the commercial ask instead — priority support, commercial licensing, invoicing — and link to the new bambuddy.cool/business.html rather than the sponsor tiers. Below that, nothing changes at all. It is the same single interruption either way: same milestones, same 14-day cooldown, same one-toast-per-session guard. Only the ask changes, so nobody sees more nagging than before. Configured printers, not active ones. The count deliberately ignores
is_active, which is the maintenance-mode flag rather than a fleet-size signal. A farm with eight machines and five of them on the bench for nozzle swaps is still a farm — filtering onis_activewould have counted three, downgraded them to the hobbyist pitch, and done it precisely when they were having the worst day. The page concedes the licence up front. business.html opens by stating plainly that Bambuddy is AGPL-3.0, that running it inside your own business costs nothing, and that no licence is required no matter how many printers you have — because that is true, and a page that implied otherwise would be a lie the audience would catch immediately. What it then offers is the set of things a licence cannot give you: priority support with a named contact and agreed response times, commercial licensing for the narrow case where you actually need it (redistribution, OEM, shipping Bambuddy on an appliance), fleet deployment and custom development, and operator training. No price list — those conversations are scoped individually. Attribution is preserved. Both surfaces keep their existing Matomo?from=tags (app-settings,app-toast-{milestone}), so the business funnel is measurable from day one on the same dashboards as the personal one, and the split between the two is visible without any new instrumentation. No telemetry was added, and none is needed: fleet size is read from the printers list the app already has cached. Scope. Frontend only — no backend change, no schema change, no migration, no new permission, no new setting. The audience split is one shared helper (utils/fleetAudience.ts) so the threshold lives in exactly one place. Tests. 7 new cases: the boundary in both directions (4 printers → personal, 5 → business); the maintenance-mode trap (8 printers with 5 inactive still reads as business); the cold-cache race (the toast waits for the fleet to load rather than defaulting to zero printers and pitching a farm as a hobbyist); both banner variants including the assertion that the commercial copy replaces the donation copy rather than sitting beside it; and the?from=tag surviving on both paths. All 7 mutation-verified — forcing the threshold out of reach, or dropping the fleet-load gate, fails them. i18n. 4 new keys (sponsors.toastBusiness,businessCta,businessTitle,businessTagline) translated in all 11 locales; parity 5616 keys. - Cam Wall on its own URL, and on a TV that isn't logged in (#2531, reporter @cadtoolbox) — The Cam Wall was reachable exactly one way: click the Cam wall button on the Printers page. It had no URL, so you couldn't bookmark it, link to it, or point a wall-mounted screen at it. It now lives at
/camwall, and a button next to the Cards / Cam wall toggle opens it there. Signed in, that page is the same wall you already know — tiles clickable, settings popover working, the knobs shared with the Printers page through the same localStorage keys, so a change in one follows you to the other. The TV case is the hard half. A screen in a workshop has no login session, and a wall tile needs two things a camera token could not previously fetch: the list of printers, and each one's status for the state badge. Both sit behindPRINTERS_READ, so a kiosk got a 401 and an empty wall. The obvious fix — let the existingcamera_streamtoken through toGET /printers— is the wrong one: that response carries every printer'sserial_numberandip_addresseven in its non-secret shape, and a URL pinned to a lobby TV lives in the browser history, in the kiosk's config file, and on the screen itself. So the Cam Wall gets a purpose-built read-only feed atGET /api/v1/camwall/printersthat serves only what a tile draws: id, name, camera rotation, connected, state, progress, layers, remaining time, HMS codes. No serial. No IP. No access code. And no filename — a token wall renders the compact overlay, so the field simply isn't served rather than being served and then hidden client-side; the part on the bed is never named to a room anyone can walk into. A second scope, not a wider one. The feed is gated on a newcamwalltoken scope alongsidecamera_stream. A Cam Wall token reaches the video and the tile metadata; a camera-stream token reaches the video and is refused by the feed. That matters becausecamera_streamtokens are already in the wild, minted by people who agreed to hand out a picture — shipping this must not retroactively grant them the ability to enumerate a fleet by name. Pick the scope when you create the token in Settings → API Keys → Camera API Tokens; the create dialog then hands you the finished kiosk URL, fully assembled, so nobody has to build it from the docs. What a token wall gives up. No settings popover and no click-through: a TV has nobody standing at it, and click-through would open a page the token cannot authenticate. The controls are not merely hidden — they aren't rendered, so a kiosk carries no focusable control it cannot act on. The overlay is capped atcompacteven if the URL asks forfull. The screen can still be tuned from the URL:?maxLive=9&interval=10&status=compact, all clamped to the same ranges the popover enforces. Statuses are polled, not pushed — the page renders outside the app layout and its WebSocket provider, and a kiosk token cannot mint a WS ticket anyway; a wall is watched, not operated, so a 5-second cadence costs nothing. Revoking the token cuts the display off on its next request. Tests. 11 backend cases: no token / garbage token / revoked token all rejected; acamera_streamtoken refused by the feed (the assertion the separate scope exists for); acamwalltoken accepted; the payload's key set pinned so a future field can't quietly add a serial, an IP or a filename; a Cam Wall token passes the camera-stream gate so its own tiles fill; a camera-stream token still passes its own gate (regression guard on #1108); and the scope allowlist pinned so adding a third scope has to be a deliberate act. 7 frontend cases covering the kiosk feed being called with the URL token, the token reaching the<img>URLs, no settings popover, inert tiles,?status=fullrefused, the expired-token message, and — the negative — a tokenless visit never touching the kiosk endpoint. Scope. New endpoint, new token scope, new route. No DB migration, no new permission, no change to the in-page wall. - Live print progress for Virtual Printers in Bambu Studio / OrcaSlicer (#1887, reporter @YozenPL) — Connect the slicer to a server-mode VP with a target printer bound and the Device tab shows the printer's AMS, temperatures and camera, but the print itself reads as a name and nothing else: no stage, no percentage, no layer count, no time remaining. The data was never missing — the bridge has the target's real
push_statuscached,mc_percentand all — Bambuddy was deliberately overwriting it with zeros. Why it was zeroed. #1558, the exact inverse complaint: a queue-mode VP that passed the live values through was read by Bambu Studio as busy, and the Send button went away for as long as the printer printed, which defeats the entire purpose of queueing. Why you cannot simply have both. Both slicers gate the Device-tab progress panel and the Send button on one and the same predicate —MachineObject::is_in_printing(), true whengcode_stateis RUNNING / PAUSE / SLICING / PREPARE.StatusPanel::update_subtask()draws the progress bar on it;SelectMachineDialog::update_show_status()disables Send on it. Report the printer's state honestly and you get progress at the cost of Send; zero it and you get Send at the cost of progress. There is no field-level trick, because it is one boolean. The fix. There is exactly one state in the gap:FINISH. StatusPanel renders the full progress panel for it (is_in_printing() || print_status == "FINISH"), SelectMachineDialog does not consider it busy. The VP already parks there after every upload — that is the #1280 / #1658 send-modal handshake — which is precisely why the reporter saw a file name and no numbers: the slicer was already drawing the widget, and we were feeding it zeros. So while the target printer is printing and the VP has no upload of its own in flight, the report now holdsgcode_state=FINISHand passes the realmc_print_stage,mc_percent,mc_remaining_time,stg,stg_cur,layer_numandtotal_layer_numthrough underneath it, at the existing 1 Hz push. Send stays enabled in every mode and #1558 does not come back. What it costs. The slicer's Pause / Resume / Stop buttons stay greyed for a server-mode VP, since it now reports a finished job rather than a running one — they were greyed before this change too, so nothing is lost; drive the print from Bambuddy, or use Proxy Mode, where the slicer talks to the printer directly and they work.print_erroris never mirrored either: a fault on the printer would raise a modal error dialog in the slicer for a machine that did not throw it, and the printer's own card already reports it. The upload handshake wins. Mirroring is suppressed while a job is being handed over (gcode_state=PREPARE) and for five seconds after the last upload transition — the slicer only releases its in-flight-job lock when it sees FINISH carrying thesubtask_nameit just uploaded, so swapping in the printer's filename mid-handshake would wedge the send modal at "Downloading". Once settled, the report switches to the job that is actually on the bed, which is the one the user wants to watch. Tests. 8 cases: progress mirrors while the target prints; the mirrored state is never one the slicer reads as busy (parametrised over RUNNING and PAUSE — this is the assertion that keeps #1558 fixed); progress stays zeroed while the target is idle, while an upload is in flight, and inside the settle window, where the slicer's own filename is still echoed back at it; mirroring resumes once the handshake has settled;print_erroris suppressed while the rest still mirrors. Verified by mutation — forcing the mirror off fails five of the eight. Scope. Backend only, non-proxy VPs with a target printer bound. No DB migration, no schema change, no new setting, no new permission, no i18n change. - Slicer Pipelines — multi-copy batches, class targeting, fanout strategies, runs dashboard, retry-failed, live WS updates (#1425 PR C — completes the v3 design) — The PR A/B drop turned slice-modal preset bundles into one-click dispatches with a pinned target printer. PR C closes the original issue with full production-batch semantics: an operator picks a saved pipeline, types in a number of copies, and Bambuddy slices once and distributes the prints across a fleet according to the pipeline's chosen fanout strategy. The runs dashboard surfaces every active and historical run with filters, per-row expandable per-copy status, cancel-in-flight, and retry-failed-copies. WebSocket pushes keep the dashboard and the in-Settings "Last run" chip live without polling. Backend.
PipelineRunCreateRequest.copies(Pydanticge=1, le=1000) replaces the implicit 1 from PR B; the orchestration loop creates onePipelineJobrow per copy.SlicerPipelineUpdateacceptstarget_kind(specific_printer/printer_class),target_model_class(Bambu model code: A1 / A1 Mini / P1P / P1S / P2S / X1 / X1C / X1E / H2D / H2D Pro / H2C / X2D), andfanout_strategy(max_parallel/round_robin/fill_one_first). A newpipeline_max_copiessetting (default 50, Pydanticge=1, le=1000) gates the copies input in the Run-with-pipeline modal and is enforced again atPOST /runtime so an API caller can't bypass the cap. PR C also addsPipelineRun.parent_run_id(nullable FK to itself, ON DELETE SET NULL) so retry runs link back to the run whose failed copies they re-attempt. Eligibility for class targeting. The matcher inservices/pipeline_eligibility.pynow branches onpipeline.target_kind: the specific-printer path is unchanged (PR B parity), the new class-targeting path enumerates everyPrinterwhosemodelmatchespipeline.target_model_class, runs the per-printer slot-by-slot check for each via astatus_lookupclosure that the route handler hands in (so the matcher stays pure-ish for unit tests), and returns a top-levelprinter_reports: list[PerPrinterReport]withokderived asanyacross the candidates. New issue kinds:no_class_matches(the install has zero printers in the chosen model class) andclass_not_set(target_kind isprinter_classbut no model was picked). The lenient-policy story is the same — operators canRun anywaypast blocking issues, andPipelineRun.eligibility_overriddenis set so the audit trail shows it. Orchestration + fanout. A new_pick_assignments(pipeline, copies)helper returns[(printer_id_or_None, target_model_or_None), …]of length copies per the picked strategy.max_parallelsetstarget_model=pipeline.target_model_classon every queue item and leavesprinter_id=None— the existing print scheduler's model-based dispatch picks any idle matching printer per item; the result is that multiple printers grab work in parallel without any new scheduler code.round_robinenumerates eligible printers (is_active=True, model matches) ordered by id and assigns copyitoeligible[i % len(eligible)]— each item gets a fixedprinter_id, the wear distributes evenly.fill_one_firstpins every copy toeligible[0]so a one-printer fleet stays one-printer even when others come online mid-run; the documented trade-off is that a printer failure freezes the queue at that printer until the operator intervenes. All three flows reuse the same slice-once path; the slice runs throughslice_dispatch.enqueueexactly as PR B did so the persistent progress toast renders end-to-end for batches just like single-copy runs. Routes.GET /pipeline-runs?limit&offset&pipeline_id&statusis the dashboard endpoint — newest-first, paginated, filterable by pipeline and persisted snapshot status.POST /pipeline-runs/{id}/retry-failedcounts the parent's failed-or-cancelled jobs at the live (queue-entry-aware) status level, builds a freshPipelineRunCreateRequestwithcopies=that countandforce=True(operator already accepted eligibility on the parent), routes it through the existingrun_pipelinehandler, and stampsparent_run_idon the result. Returns 400 when the parent's source or pipeline was deleted, or when there are no failed copies to retry.POST /pipeline-runs/{id}/cancelextends PR B's cancel to cascade across N queue entries — only the ones still inpending/queuedare touched so in-flight prints continue on the printer (operator must Stop on the machine). WebSocket. Newpipeline_run_updatedevent type carries the full materialisedPipelineRunResponseand fires on every state transition (queued → slicing → dispatching → in_progress → completed | failed | partial_failure | cancelled). Per-user routing viaws_manager.broadcast_to_user(run.created_by, …)so each operator sees their own runs without cross-user noise; auth-disabled installs broadcast to all connections (PR B's pattern). The frontend'suseWebSocketswitch handles it by invalidating both['pipeline-runs-all'](the dashboard) and['pipeline-runs', pipeline_id](the per-pipeline "Last run" chip in Settings). The dashboard still polls every 15 s as a belt-and-suspenders for missed messages. Run status roll-up. A new_roll_up_run_statusfunction computes the run-level status from the per-job statuses at read time: all-completed →completed, any in-flight →in_progress, some completed + some failed → the newpartial_failurestatus (this is what gets the Retry-failed button), all failed →failed. The persisted snapshot is still written on terminal transitions for the dashboard's status filter to remain useful.copies_completed/_failed/_cancelled/_in_progresscounts ride on the response so per-row "1/3 · 2 failed" summaries don't need a second query. Frontend. The Settings → Workflow → Pipelines pipeline editor grows three new controls in the edit form: a radio fortarget_kind(Specific printer / Printer class), a model-class picker filtered to the models present on at least one installedPrinterrow (so users can't pick "H2C" if they only have X1Cs), and a fanout-strategy radio with the three options labelled with their use cases. The read-only row reflects class targeting with a "X1C · Round robin" line in place of the printer name.RunWithPipelineModalgrows a number input for copies bounded bysettings.pipeline_max_copies, accepts class-targeted pipelines (the "Apply pipeline" button is enabled when the pipeline has either a pinned printer OR a class target), and the pipeline-list row shows "Any X1C" instead of a printer name for class pipelines. The "Run pipeline" Setting → Workflow → Queue & Dispatch sub-tab gets a new "Slicer Pipeline limits" card with the max-copies input (bounded 1–1000 client-side, server enforces the same). New dashboard page at/pipelines/runs(sidebar entry under Print Queue, gated onpipelines:read). Lists every run across every pipeline with two dropdown filters (pipeline + persisted snapshot status) and pagination at 25 per page. Each row shows pipeline name, status chip (partial_failureis amber), source file, created-at timestamp, and "{completed}/{copies}" + "{failed} failed" rollup. Click the chevron to expand a per-copy panel listing eachPipelineJob's assigned printer + status + error message. In-flight runs get a Cancel button; partial-failure / failed runs get a Retry-failed button. i18n. ~43 new keys acrossnav.pipelineRuns,pipelineRuns.*(title / filters / pagination / job-status chips / toasts),settings.pipelines.field.*(targetKind / fanout / class),settings.pipelines.runs.status.partial_failure,settings.pipelineLimits.*,library.runWithPipeline.*(copies / copiesHint / classTarget / issue.noClassMatches / issue.classNotSet), andcommon.previous/common.next— translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5516 leaves per locale, no English fallback.Copies/{{n}} copies/max {{n}}added to the French + Italian cognate allowlists where they're genuine. Tests. Six new backend cases intest_pipeline_runs_api.pycovering copies-cap rejection (schema gate at 1000), 3-copy run creates 3 jobs with sequentialcopy_index, class eligibility with two X1C candidates returns a 2-entryprinter_reportsarray, class eligibility with no matching printers in install returnsno_class_matches, dashboard list endpoint with pagination + status filter, retry-failed correctly counts failed jobs from a partial-failure parent and stampsparent_run_id. Plus the existing 16 PR A/B cases were lightly updated whereclass_not_setis now a valid no-target signal alongsideprinter_not_set. Five new frontend cases inPipelineRunsPage.test.tsxpin the dashboard's empty state, list rendering, Cancel button on in-flight runs, Retry-failed button on partial-failure runs, and per-row expand to show jobs. Three updated frontend cases (RunWithPipelineModal.test.tsx) assert the new four-arg signature onrunPipeline(pipelineId, source, force, copies). One updatedSettingsPage.test.tsxsidebar-order test reflects the newpipelineRunsnav entry betweenqueueandprojects. Suites.pytest -n 30 backend/tests/6539/6539 green;npx vitest run2284/2284 green (173 files);npm run buildclean;python -m ruff check backend/clean;node scripts/check-i18n-parity.mjsclean. Scope. PR C closes the v3 design — no further pipeline PRs are queued. The existing print scheduler's model-based dispatch (PrintQueueItem.target_model+target_location+required_filament_types) is the only thing that makes class targeting actually distribute work; PR C just plugs into it. Thefill_one_firststrategy's "one printer fails, queue stalls" trade-off is documented in the editor's option-row hover-hint and in the orchestrator code comment — it's the correct behaviour for "I want one printer to finish a batch end-to-end" and the wrong behaviour for "I want resilience"; the right strategy for resilience ismax_parallel. Cross-printer-class pipelines (e.g. one pipeline targeting "any X1C OR P1S") remain out of scope — make two pipelines, one per class. - Slicer Pipelines — Archive entry point + progress toast for pipeline-driven slicing (#1425 PR B follow-up) — Two real gaps from the PR B drop. (1) The Run-with-pipeline button only existed in the file manager — operators who keep their working files in archives had to copy them out to the library to use a pipeline. (2) Triggering a slice via a pipeline produced a silent multi-second-to-minute wait — the manual SliceModal flow has the sticky
Slicing X — Generating G-code 75%persistent toast, the pipeline path went throughasyncio.create_taskdirectly and never registered withSliceJobTracker. Fix. (1)POST /slicer-pipelines/{id}/check-eligibilityandPOST /slicer-pipelines/{id}/runnow acceptsource_archive_idas an alternative tosource_library_file_id(XOR — Pydantic validator rejects both-set and neither-set), and the eligibility-check and orchestration paths branch via_resolve_sourcewhich readsarchive.source_3mf_pathwith a fallback toarchive.file_path.PipelineRun.source_archive_idis a new nullable FK column (Postgres + SQLiteALTER TABLEinrun_migrations— idempotent via_safe_execute).PipelineRunResponseechoes the field. ArchiveCard's context menu picks up aRun with pipelineitem alongside the existing Slice action (only on source archives — gcode archives already have Print + Open in BambuStudio), gated onuseSlicerApi+pipelines:run. Path-safety:Path(base_dir) / archive.source_3mf_pathcarries aSEC-PATH-OKmarker citing the upload-time validator at_resolve_source_3mf_path(same comment style asroutes/archives.py:3955); theLibraryFile.file_pathsite gets the same treatment. (2) The pipeline orchestrator is now theruncallable of aslice_dispatch.enqueuecall — the same dispatcher the manualSliceModalflow uses — instead of a bareasyncio.create_task. The SliceJob's lifecycle (pending → running → completed/failed) drives the existing progress toast end to end: same persistent toast, sameGenerating G-code 75%weave from the sidecar's--pipechannel, same auto-replace with a transient success/error toast on terminal.PipelineRun.slice_job_idis set on the run row before the route returns 202, so the frontend can calluseSliceJobTracker().trackJob(slice_job_id, source.kind, source.filename)fromRunWithPipelineModal'srunMutation.onSuccess— same one-call surface thatSliceModal's slice mutation already uses. (3)RunWithPipelineModal'ssourceprop is now{kind: 'libraryFile' | 'archive', id, filename}(mirrorsSliceModal.SliceSource);api.checkPipelineEligibility+api.runPipelinetake a discriminated-union source argument and route to the right backend field.PipelineRunTS type growssource_archive_id. Tests. Three new backend cases intest_pipeline_runs_api.py— archive-source happy path (creates a PrintArchive row + on-disk file, posts withsource_archive_id, verifies the response carriessource_archive_id+slice_job_idfrom a stubbedslice_dispatch.enqueue), XOR rejection both-set, XOR rejection neither-set. The existing three run/cancel cases were updated to patchbackend.app.services.slice_dispatch.slice_dispatch.enqueue(the new mock target) instead of the removed_run_pipeline_orchestrationhelper, and the run-happy-path now assertsslice_job_id == 9001arrives on the response. One new frontend case inRunWithPipelineModal.test.tsxpins the archive flow end to end (checkPipelineEligibilitycalled with{kind: 'archive', id: 7}, thenrunPipelinewith the same). The existing fast/slow path tests were updated to wrap inSliceJobTrackerProvider(the newuseSliceJobTrackerhook requires it) and to assert the new discriminated-union source argument. Suites.pytest -n 30 backend/tests/6533/6533 green;npx vitest run2279/2279 green (172 files);npm run buildclean;python -m ruff check backend/clean;node scripts/check-i18n-parity.mjsclean. Scope. No new i18n keys — both fixes reuse the existing PR B keys. No new permission. The archive flow only branches at the source-resolution layer; everything downstream (eligibility, slice, queue dispatch) is the same code path the library flow uses. PR C scope (multi-copy + class targeting + fanout) is unchanged. - Slicer Pipelines — Run a pipeline on a file with one click (#1425 PR B) — PR A landed the bundle (save & apply preset slots in the SliceModal). PR B turns that bundle into an actual one-click dispatcher: file-manager rows now carry a
Run with pipeline ▾button that slices the source through the pipeline's pinned printer/process/filament/bed-type combo and enqueues the print on the pipeline's pinned target printer. Scope. Single-target dispatch —target_kind='specific_printer'only. Multi-copy batch + class targeting + fanout strategies are PR C; the schema columns are already in place from PR A so PR C is code-only. Backend. Two new SQLAlchemy models —PipelineRun(one row per Run-pipeline click, carries the slice_job + sliced_library_file ids + snapshot status) andPipelineJob(one row per copy; PR B always 1, PR C variable). Soft-link to slicer_pipelines viaondelete='SET NULL'so run history survives a pipeline delete; same for source_library_file.statuson the run is a persisted snapshot that gets terminal transitions written (slice failure, cancel, completion); in-flight reads roll up the live state of the linked queue entry via_compute_run_status— that keeps the status accurate (pending → printing → completed) without a background watcher writing on every queue tick. Eligibility matcher atservices/pipeline_eligibility.py— given a pipeline + the livePrinterStatefromprinter_manager.get_status, returns a structured report with typed issues:printer_not_set,printer_not_found,printer_disabled(fromPrinter.is_activeshipped with #1476),printer_offline,filament_type_mismatch,filament_color_mismatch,ams_slot_missing,filament_unverified(cloud/standard tier presets can't be statically read here; surface as info, not a block). Canonical filament-type map mirrorsprint_scheduler._canonical_filament_typesoPLA Basic/PLA Matte/ etc. all collapse toPLAfor the type comparison; colour normalises to six-hex-digit lowercase. Eligibility is lenient with confirmation — the report drives the frontend confirmation modal, but the user canRun anyway(setseligibility_overridden=Trueon the run row so the audit trail shows which runs bypassed pre-flight). Routes. Two new routers —pipeline_run_create_routermounted under/slicer-pipelines(POST/{id}/check-eligibility, POST/{id}/run, GET/{id}/runs?limit=N) andpipeline_run_routerat/pipeline-runs(GET/{id}, POST/{id}/cancel).POST /runreturns 202 with the run shape; orchestration happens in a fire-and-forgetasyncio.create_taskthat opens its own DB session (the request's session is closed by the time it runs) and walks: status='slicing' →slice_and_persistwith the pipeline'sSliceRequest→ on successstatus='dispatching'+ insertPrintQueueItemwithprinter_id=target_printer_id, library_file_id=sliced_library_file_id. The existing scheduler picks the queue entry up on its next tick.POST /runwith eligibility issues and noforcereturns 409 with the report insidedetailso the frontend can render the same confirmation modal it would for an explicit pre-flight;force=truebypasses the 409 but a missingtarget_printer_idstill 400s (defence in depth — the UI can't enqueue the print without a target).POST /cancelis idempotent on terminal states and cascades to the linked queue entry when its status is stillpending/queued(in-flight prints continue — operator must Stop on the printer itself). SlicerPipeline.target_kind / target_printer_id become writable viaPUT /slicer-pipelines/{id}— the schema accepts both fields, the route treatstarget_printer_id=0as "clear" (the empty-<option>HTML coercion) and a positive value as a literal FK. Frontend. SlicerPipelinesPanel in Settings → Workflow → Pipelines extends its edit form with a target-printer<select>(populated fromapi.getPrinters()); pipelines without a target render an amber "Set a target printer to run this" hint in the row + a "Set a target printer before running this pipeline" warning at the bottom. Last-run summary appears inline per row — smallLast run: completed · 27/06/2026, 14:23line driven byGET /slicer-pipelines/{id}/runs?limit=1with a 15 srefetchIntervalso the chip ticks while a run is in flight.RunStatusBadgecolour-codes the seven states. New componentRunWithPipelineModalatcomponents/RunWithPipelineModal.tsx— two-step dialog: step 1 lists the user's pipelines (each row shows the pinned target printer; pipelines without a target are disabled with aNo target printer sethint), step 2 is the eligibility confirmation. Fast path: ok=true skips step 2 entirely and fires the run straight from the pipeline pick. Slow path: shows per-issue text via theIssueTextmapper — eg.Filament slot 1: expected PLA, AMS has PETGforfilament_type_mismatch,AMS slot 2 not available on this printerforams_slot_missing— thenRun anywayposts withforce=true. FileManagerPage integration: FileCard's action menu picks up aRun with pipelineentry (gated on the newpipelines:runpermission); list-view rows get a matching inline Play-icon button so list users have the same entry point as card users. Both flow into the samesetRunPipelineFile(file)state which renders the modal. The action is only offered on slice-eligible files (3MF / STL / STEP) and only whenuse_slicer_apiis on — matches the existing Slice button gating, since a non-slice-eligible file can't reach the slice step in any case. Frontend types: client.ts growsPipelineEligibilityReport,PipelineRun,PipelineJob,PipelineRunListResponse, plus six newapi.*methods (checkPipelineEligibility,runPipeline,listPipelineRuns,getPipelineRun,cancelPipelineRun, and the updatedupdateSlicerPipelinewhich now acceptstarget_kind+target_printer_id). ThePermissionunion also getspipelines:read | pipelines:write | pipelines:run— these were on the backend Permission enum from PR A but had been missed in the frontend union (caught when TS rejectedhasPermission('pipelines:run')). i18n. ~36 new keys acrosslibrary.runWithPipeline.*(modal title / confirm / source-hint / pipeline-hint / target-hint / Run-anyway / 8 issue-kind strings / 2 toast / empty-state / no-target hint) andsettings.pipelines.field.targetPrinter/field.noTarget/noTargetHint/noTargetWarning/runs.lastRun+ sevenruns.status.*strings — translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5473 leaves per locale, no English fallback. The stringslicingwas added toIT_COGNATES(genuine cognate — same word in Italian). Tests. 13 new backend integration cases intest_pipeline_runs_api.pycovering PUT target write + clear-via-0 + check-eligibility (printer_not_set / printer_disabled cascade with offline / fully-clear AMS-match) + run flow (409 on issues+!force / 400 on force+!target / 202 on clean path with creation of run+job) + list/get 404s + cancel (404 / marks queued / idempotent on terminal). Slicing itself is stubbed viapatch(..._run_pipeline_orchestration)so CI runs without a live sidecar. 4 new vitest cases inRunWithPipelineModal.test.tsxpin the modal's two-step flow: empty state, disabled pipeline-without-target, fast-path (issues empty → modal closes immediately afterrunPipeline(..., false)), slow-path (issues shown →Run anywayposts withforce=true). Suites.pytest -n 30 backend/tests/6530/6530 green;npx vitest run2278/2278 green (172 files);npm run buildclean;python -m ruff check backend/clean;node scripts/check-i18n-parity.mjsclean. What's out of scope for PR B. Multi-copy (copies > 1), class targeting (target_kind='printer_class'), fanout strategies, the Pipeline Runs dashboard — all PR C. Painted multi-filament 3MFs still hit the upstream OrcaSlicer CLI gate (OrcaSlicer/OrcaSlicer#13774); the slice step inside the pipeline run fails the same way the standalone slice route does, the run rolls up tostatus='failed'with the slicer's error string inerror_message. The print queue's existing AMS / filament check + the printer-side error path remain authoritative for what actually happens at the machine — pipeline eligibility is a pre-flight, not a hard guard. - Slicer Pipelines — save & reuse a preset bundle in one click (#1425 PR A, requested by @TheUltimateC0der) — Top feature in the first sponsor vote. The SliceModal forces the user to pick four slots every time: printer / process / filament(s) / bed type. For fleet production that's tedious and error-prone — operators want a named "Production PLA" bundle they can apply with one click on every file and every printer. PR A scope. Definitions only. The new model
slicer_pipelinesmaterialises the bundle plus future-PR columns (target_kind,target_printer_id,target_model_class,fanout_strategy) so PR B (single-target dispatch) and PR C (multi-copy batch with capability-matched fanout) are code-only, not migrations. The bundle is independently useful in PR A as an ergonomic improvement: pipelines are picked from the SliceModal, applied to the four slots, then sliced through the existing flow. No new dispatch behaviour yet. Backend. ModelSlicerPipeline(models/slicer_pipeline.py), Pydantic schemasSlicerPipelineCreate/Update/Responsereusing the existingPresetRefshape fromschemas/slicer.py, CRUD routes at/api/v1/slicer-pipelines/(GET list,POST create,GET/PUT/DELETE by id). Soft-delete viais_deletedso PR B+ run history can still resolve pipeline metadata after the operator removes one. Listed newest-first byid DESC(more reliable thancreated_atunder back-to-back inserts whose DateTime precision can tie). Routes use explicitawait db.commit()after the mutation (matches theroutes/library.pypattern) so the response shape returns the committed row. Permissions. Three newPermissionvalues:PIPELINES_READ,PIPELINES_WRITE,PIPELINES_RUN. PR A only consumes the first two;RUNis defined now so PR C doesn't need to backfill.AdministratorsandOperatorsget all three;ViewersgetPIPELINES_READ. A backfill block inseed_default_groups()adds them to existing groups on upgrade (mirrors thelibrary:purge/archives:purgepattern from earlier). All three are added to_APIKEY_DENIED_PERMISSIONSso they fail closed for any API-key surface — PR B / PR C may movePIPELINES_RUNontocan_queueonce the dispatch lands. Frontend. Settings → Workflow tab is split into two sub-tabs mirroring the Authentication tab's pattern: Queue & Dispatch (the existing Workflow content) and Pipelines (the new manager). The Workflow sidebar entry stays single — no expandable submenu — and the sub-tab choice is reflected in the URL (?tab=queue&sub=pipelines) for deep-linking. SlicerPipelinesPanel lists saved pipelines with inline rename, soft-delete, and a stale-preset warning when a referenced preset no longer resolves against the unified-presets listing (e.g. anorca_cloudpreset deleted in OrcaSlicer; the pipeline still saves, the warning prompts a re-save from the SliceModal). Full pipeline creation lives in the SliceModal rather than Settings — the user has already done the four-slot work there. The modal grows anApply pipeline ▾dropdown plus aSave as pipelinebutton above the existing preset dropdowns. Apply fills all four slot states (printerPreset,processPreset,bedType,filamentPresets[]); the filament list right-pads from current state so a pipeline with fewer entries than the current source's slot count keeps the existing tail (lets the same pipeline apply across single-color and multi-color files). Save captures the four-slot picks under an inline-named pipeline. Stale-preset warning shows on the Settings list, not blocking apply, so an old pipeline with a one-deleted-preset can still be re-applied and re-saved with the new pick. i18n. ~30 new keys acrosssettings.pipelines.*andslice.pipelines.*plussettings.tabs.queueDispatch/queuePipelines, translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5437 leaves per locale, no English fallback.Pipeline/Pipelines/Filament {{n}}added toIDENTICAL_TO_EN_ALLOWEDfor the locales where they're genuine cognates (de / es / fr / it / pt-BR / tr). Tests. Backend: 11 integration cases intest_slicer_pipelines_api.pycovering empty list, create + round-trip, get-by-id, partial PUT preserves untouched fields, filament list replaces wholesale, soft-delete hides from list + GET-by-id, 404s on missing, schema rejection of empty filament list + invalid PresetRef source, newest-first ordering. Frontend: 3 new SliceModal cases (apply-pipeline dropdown disabled-empty / apply-sets-state / save-as-pipeline-round-trip) plus 2 SettingsPage cases (sub-tab nav renders + Pipelines deep-link). Existing SliceModal tests adjusted via apresetSelects()helper that filters out the new Apply-pipeline combobox so historicalselects[0]indexing into printer/process/filament remains stable. Suites.pytest -n 30 backend/tests/6517/6517 green;npx vitest run2274/2274 green (171 files);npm run buildclean;python -m ruff check backend/clean;node scripts/check-i18n-parity.mjsclean. Scope. No new dispatch behaviour yet — pipelines are a preset-bundle convenience layer in PR A. PR B adds single-target dispatch (thetarget_kind='specific_printer'path), PR C adds multi-copy batch with capability matching + the three fanout strategies (max_parallel/fill_one_first/round_robin). TheRun pipelineaction mentioned in the original issue is PR B/C and intentionally not exposed in this drop. Painted multi-filament 3MFs still hit the upstream OrcaSlicer CLI gate (OrcaSlicer/OrcaSlicer#13774); the slice fails, the pipeline doesn't pre-validate. - Sticky upload-progress toast restored for scheduler-driven dispatch (#1625 follow-up) —
#1625(Unify print dispatch through the scheduler) moved every print's FTP push to the printer into the server-side scheduler tick, which means the user's click no longer carries an XHR withprogressevents — the old browser-side upload modal had nothing to show because there was no browser-side upload anymore. Users only saw the queue item flip to "active" with no visibility into the multi-second to multi-minute FTP push + the H2D/H2D Pro 80–210 sproject_filedigestion window before the printer actually started extruding. Fix. The legacy bg-dispatch toast rendering from0b43ac0d:frontend/src/contexts/ToastContext.tsxlines 510–650 is ported back in place verbatim — same DOM tree, same Tailwind classes, sameformatFileSizebytes line, same uppercase status chip, same collapse chevron, sameawaitingPrinterderivation, same auto-dismiss-when-all-terminal — only adapted to read from the four scheduler-side WS events introduced here instead of the legacybackground-dispatchaggregate event. Materialization only on actual upload start. The toast appears when the FTP push to the printer starts (queue_item_uploading), NOT onPOST /queue— a draft that emitted at queue-add time made the toast jump to "Dispatched" before any upload had happened. Four backend lifecycle WS events drive the rendering:queue_item_uploading(start of FTP, carriesprinter_name+total_bytesfromfile_path.stat().st_size),queue_item_upload_progress(throttled byte-level updates — first call always emits + emit when ≥200 ms elapsed OR ≥256 KB transferred since last emit, plus always emit atbytes_transferred >= total_bytes; this matches the legacybackground_dispatch.py:614-615gates 1:1 so the bar feels identical on small AND large files; a single shared_UploadProgressBridgeinstance bridges from the FTP executor thread back to the asyncio loop viarun_coroutine_threadsafe),queue_item_acked(watchdog confirmed printer transitioned out ofpre_state),queue_item_failed(any error, with areasonkey the toast looks up asdispatchToast.failed.{reason}for upload-vs-start-command differentiation, generic fallback). Noqueue_item_dispatchedevent — the legacy bg-dispatch path keptstatus='processing'from upload start until printer ack, and the "Awaiting printer…" subtitle is derived purely fromupload_progress_pct >= 99.9(the legacyuploadDoneAwaitingPrintertrick at line 568-572). An explicitdispatchedevent would push the status chip out ofPROCESSINGprematurely — which is exactly what the first screenshot-iteration showed. Per-user routing. Newws_manager.broadcast_to_user(user_id, msg)filters connections bywebsocket.state.bambuddy_principal_user_id— resolved once at WS connect time via aselect(User.id).where(User.username == principal)lookup so per-message routing is O(connections) not O(connections × DB). Auth-disabled installs routeuser_id=Noneto all connections, matching the legacy single-user toast behaviour. The watchdog success path receivescreated_by_idvia a new kwarg so the static_watchdog_print_startmethod can still emit theackedevent without re-fetching the queue item. Backend. ~110 LOC across 3 files:core/websocket.py(broadcast_to_user+ four event helpers,bambuddy_principal_user_idfilter on each connection),api/routes/websocket.py(principal username → User.id resolve at connect, stashed onwebsocket.state.bambuddy_principal_user_id),services/print_scheduler.py(_UploadProgressBridgethread-safe throttle class,queue_item_uploadingemitted before FTP withprinter.name,progress_callback=plumbed into both thewith_ftp_retryand directupload_file_asyncbranches via**kwargs,queue_item_failedat the FTP-fail spot, watchdog success path emitsackedon both Phase A and Phase B exits). Frontend. Rendering ported in place tocontexts/ToastContext.tsx(dispatchDatafield onToast, ingestuseEffectmapping the fourbambuddy:dispatch-toastevent types to legacyDispatchToastJobshape, terminal-state auto-dismissuseEffect; legacy rendering block reused 1:1 minus the cancel button — BG dispatch's/background-dispatch/{id}DELETE doesn't exist in the scheduler model and adding it is out of scope).hooks/useWebSocket.tsforwards the fourqueue_item_*cases viawindow.dispatchEvent(new CustomEvent('bambuddy:dispatch-toast', { detail })), matching the existingplate-not-empty/unknown-tagpatterns. i18n. 11 keys × 11 locales underdispatchToast(de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW):untitled/startingPrints/progressSummary(header{{complete}}/{{total}} complete • Processing: {{processing}}—Dispatched: Xfrom the legacy summary was dropped because the scheduler has no pre-upload "dispatched" state) /expandDetails/collapseDetails/awaitingPrinter/status.{processing|completed|failed}/failed.{generic|upload_failed|start_command_failed}/dismiss. Locale parity check 5401 leaves per locale, no English fallback. Tests. Backendtest_ws_broadcast_to_user.pypins the routing contract (filter by user_id, fan-out on None, payload shape withprinter_nameforuploading, server-side pct compute including divide-by-zero);test_upload_progress_bridge.pypins the throttle (first call always emits, 256 KB byte gate honoured even when time gate would skip, completion always emits, no-op on zero bytes, no-op when no loop). Frontend__tests__/contexts/DispatchToastContext.test.tsxpins the materialization-on-uploading invariant (stray progress / acked event before anyuploadingdoes NOT render — regression guard), the uploading → "Awaiting printer…" → acked lifecycle with status chip stayingPROCESSINGthrough the whole upload (regression guard for the screenshot-reported "Dispatched: 1 immediately" bug), 3.5 s auto-dismiss when terminal, concurrent jobs sharing one wrapper, collapse + dismiss buttons. Suites.pytest -n 30 backend/tests/unit/test_ws_broadcast_to_user.py backend/tests/unit/test_upload_progress_bridge.py backend/tests/integration/test_print_queue_api.pygreen;vitest run src/__tests__/contexts/49/49 green;ruff check backend/clean;npm run buildclean. Scope. No DB migration. No new permission. Thebambuddy:dispatch-toastwindow event is internal to the frontend bundle, not a public hook — third-party plugins should not subscribe to it. The 0–30 s scheduler-tick pickup wait is unchanged; this fix only addresses visibility of what happens once the upload starts. Tiny test files that upload in a single FTP chunk will still jump straight to "Awaiting printer…" because the first-and-last progress callback is one and the same event — same edge as the legacy bg-dispatch behaviour on sub-256 KB files. - Cam Wall: don't kill shared streams when one viewer closes + offline tiles show OFF, not LIVE — Two small but load-bearing fixes against the new cam-wall view. (1) Offline tile chip. A disconnected printer (
status.connected === false) was still assignedlivemode byCameraWall.modeByPrinter— it consumed aMax live streamsbudget slot AND rendered the redLIVEchip on top of theWifiOffplaceholder. The allocator now treats!connectedlike off-screen — assignspaused, leaves the live budget intact. The existingCameraTilerendering (WifiOfficon, darkOffchip) takes over automatically. Side effect: an 8-printer wall with 2 offline X1Cs no longer wastes 2 of the 4 default live slots on dead tiles. (2) Shared-broadcaster teardown./api/v1/printers/{id}/camera/stopis the unmount cleanup for every camera consumer (CameraTile,EmbeddedCameraViewer, popupCameraPage). It used to unconditionallyshutdown_broadcaster(f"printer-{id}")+ kill every ffmpeg in_active_streamswhose key starts with{printer_id}-. The fan-out broadcaster is shared across all viewers of the same printer, so closing the embedded viewer while the cam-wall tile of the same printer was visible force-killed the source the tile was pulling from — the tile's<img>errored out and showedNo signaluntil the user navigated away. The broadcaster itself already has correct natural-shutdown semantics: each subscriber's HTTP teardown callsunsubscribe(queue), and when the count reaches 0 the broadcaster's own_grace_then_stopwaits_GRACE_SECONDS(5 s) before tearing down — re-checking under the lock so a new subscriber rejoining cancels the shutdown./camera/stopwas just a fast-cleanup shortcut for the single-viewer case. Fix. Newget_subscriber_count(key)accessor incamera_fanout.pyexposes the broadcaster'ssubscriber_count(the private list-len already used internally). The/camera/stoproute now readsget_subscriber_count(f"printer-{printer_id}")BEFORE the force-teardown; when ≥ 1 subscriber is still attached, it returns{"stopped": 0, "skipped": true}early and leaves the broadcaster + ffmpeg processes alone. The leaving viewer's HTTP teardown still runs the naturaliter_subscriber.finally → unsubscribepath, so its subscription is correctly released; the broadcaster keeps serving the other viewer(s). Single-viewer close still hits the force-teardown path immediately (no subscribers remain at all). Cost: in the race where the leaving viewer's HTTP teardown has already propagated to the broadcaster at the moment its/camera/stopPOST lands (count just dropped to 0), force-teardown still runs and we miss the optimization for a different actually-still-subscribed viewer — but the natural grace-shutdown bounds the worst case at 5 s of ffmpeg tail, not a stuck stream. Verified by inspection: this race only matters when subscriber_count transitions through 0 between the HTTP teardown and the POST, which requires both viewers' tabs to close in lockstep — practically unobservable. Tests. Newtest_stop_camera_stream_skips_shutdown_when_subscribers_remainintest_camera_api.pypatchesget_subscriber_countto return 2 and asserts/camera/stopreturns{stopped: 0, skipped: true}, does NOT callshutdown_broadcaster, and does NOT terminate any_active_streamsffmpeg process. The existing 6 stop-route tests stay green because they don't pre-populate subscribers —get_subscriber_countreturns 0, the early-return doesn't trigger, and the existing force-teardown still runs. Fulltest_camera_api.py43/43 green.ruff check backend/clean. Frontendnpm run buildclean. Scope. No API contract change — the existing{"stopped": int}shape is preserved, the new"skipped"field is additive. No new permission. No DB migration. No i18n change. - Cam Wall: per-tile print/printer status overlay — Cam-wall tiles now surface live printer state on top of the camera image instead of being a pure video grid. A new gear-menu toggle
Status overlayswitches betweenOff,Compact, andFull(defaultFull). Compact paints a colour-coded state chip in the top-left corner —Printing/Paused/Finished/Error— bucketed using the sameclassifyPrinterStatusrules that drive the printer-card badges, withIdledeliberately suppressed so a wall of cold printers stays visually quiet. Full adds a bottom info strip on tiles whose state isPrintingorPaused: the active file'ssubtask_name ?? gcode_file, the rounded progress percent,Layer N/Mwhen both are known, and the remaining time formatted by the existingformatDuration(remaining_time * 60)helper fromutils/date.ts— so the numbers match what the printer card shows for the same printer. When the printer's known HMS errors are non-empty (filtered via the existingfilterKnownHMSErrorsfromHMSErrorModal), the chip flips to the redErrorcolour with alucide-reactAlertTriangleicon inline. The whole overlay layer is gated byconnected— disconnected and paused-mode tiles render the existing offline / paused placeholders unchanged. Zero new network cost.CameraWall.tsxalready ranuseQueries({ queryKey: ['printerStatus', id], ... })against every printer for the connected flag; the patch widens theuseMemoto expose the fullPrinterStatuspayload and threadsstate,progress,remaining_time,layer_num,total_layers,subtask_name,gcode_file, and the filtered HMS error count into eachCameraTile— same shared React Query cache thePrinterCardflow populates, so Cards ↔ Cam Wall flips remain instant and the wall opens no second status fan-out. Settings. Per-user, persisted inlocalStorageundercamWallStatusModealongside the existingcamWallMaxLiveandcamWallSnapshotSeckeys. The picker is a three-segment button row inside the existing cam-wall settings popover (gear icon, click-outside dismiss), labelledOff/Compact/Full. DefaultFullbecause the cards already show this info — users who pick cam-wall view still want to glance the same details without flipping back. CameraTile contract. All new props (statusMode,printerState,progress,remainingMin,layerNum,totalLayers,printName,hmsErrorCount) are optional with safe defaults, so the 5 existing vitest cases inCameraTile.test.tsxcontinue to pass unmodified — the status layer is purely additive on the leaf component. The state-bucket classifier lives co-located inCameraTile.tsx(mirrorsPrintersPage.classifyPrinterStatusforRUNNING/PAUSE/FINISH/FAILED) so the tile renders correctly even if called outside the cam-wall scheduler. Temperatures intentionally not surfaced. Nozzle / bed / chamber readouts would crowd the tile and overlap the existing top-right LIVE/SNAP/OFF mode indicator and bottom-edge printer name; the printer card remains the canonical surface for those. i18n. 7 new keys underprinters.camWall(layer,timeLeft,statusMode.{off,compact,full},settings.statusOverlay,settings.statusOverlayHint) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) — no English fallback. State chip labels reuse the existingprinters.status.{printing,paused,finished,error,idle}keys so no new translation work was needed for the bucket vocabulary. Parity scriptcheck-i18n-parity.mjsadds two legitimate-cognate exceptions:Compactfor French (same word) andOfffor Italian (universal loanword); both remain real translations in every other locale. Parity check 5388 leaves per locale. Scope. No backend change. No new request. No new permission. No DB migration. The toggle defaults toFull, so installs see the overlay the first time they open Cam Wall — flipping toOffreverts to the original camera-only behaviour. - Cam Wall view on the Printers page — New view toggle next to the card-size selector flips the entire printers list into a responsive grid of live camera tiles (
Cards↔Cam wall). Reuses the existing per-printer FTP / RTSPS proxy on/api/v1/printers/{id}/camera/stream, so the backend ffmpeg fan-out is the same one EmbeddedCameraViewer already drives — no new server-side state machine. Bandwidth ceiling matters on the RPi installs (bambuddy-install-base-2026-06-20 documents that the median deployment is a Pi 4): each live tile is one TLS pull + one MJPEG fan-out. To stay sustainable on a Pi 4 with 8+ printers, only the tiles currently on-screen are live, and only up toMax live streams(default 4) at any moment — everything else falls back to per-tile snapshot polling against/api/v1/printers/{id}/camera/snapshotat a configurable interval (default 8 s). Tiles that scroll off-screen pause entirely. Architecture.frontend/src/components/CameraTile.tsxis the leaf — three modes (live/snapshot/paused), a single<img>element withloading="lazy", anonErrorno-signal fallback, and auseEffectcleanup that POSTs/camera/stop(withkeepalive: true) on mode-out-of-live AND on unmount so the backend releases the transcoder slot. Same/camera/stopdiscipline EmbeddedCameraViewer uses, so a tile that scrolls off the wall is byte-identical to closing a floating viewer.frontend/src/components/CameraWall.tsxis the scheduler — anIntersectionObserver(threshold 0.4 to avoid flicker at scroll boundaries) tracks visibility, then auseMemowalks the printer list in sort order and assigns the first N visible tiles tolive, the rest of the visible set tosnapshot, and off-screen tiles topaused. The walker is stable on a given render (no LRU eviction churn) which avoids the "tile flickers between live and snapshot every frame" failure mode. Reuses the same['printerStatus', id]React Query cache eachPrinterCardalready populates, so flipping between Cards and Cam Wall is instant and the wall doesn't open a second status fetch fan-out. Clicking a tile honours the existingSettings → camera_view_modepreference — opens the floatingEmbeddedCameraViewerwhen set toembedded, otherwise pops the/camera/:idwindow with the saved size/position fromcameraWindowState. Settings. Both knobs are per-user, persisted inlocalStorage(camWallMaxLive,camWallSnapshotSec) — not a global backend setting, since a Pi 4 user and a NUC user looking at the same install want different caps. Bounded[1, 16]for max live and[2, 60]seconds for snapshot interval, both rendered as an inline gear-icon popover above the grid with click-outside dismiss. The Cam Wall button is permission-gated oncamera:view; viewers without the permission see it disabled. The card-size selector goes opacity-40 + pointer-events-none in cam-wall mode (tile size is governed by the responsive grid, not the cardSize knob). i18n. 13 new keys (printers.pageView.cards,printers.pageView.camWall,printers.camWall.{noPrinters,noSignal,live,snap,off,summary},printers.camWall.settings.{title,maxLive,maxLiveHint,snapshotInterval,snapshotIntervalHint}) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) — no English fallback. Parity check 5369 leaves per locale. Tests. 5 new vitest cases infrontend/src/__tests__/components/CameraTile.test.tsxcover live URL emission withfps=8, snapshot URL emission with the cache-bust counter advancing on the interval, offline placeholder for disconnected printers, paused placeholder rendering, and the/camera/stopPOST firing when the tile transitions out of live. Scope. No backend change. No DB migration. No new permission. The existingEmbeddedCameraVieweris untouched — Cam Wall is purely additive. TheprinterPageViewtoggle defaults tocards, so installs see no behaviour change until a user picks Cam Wall. - AMS drying badge now shows the active cycle's filament + target temperature — During an active drying cycle the AMS card on the printers page renders
Drying · PETG @ 65°C · 11h 35m left(the loaded-filament line under the slots) instead of the bareDrying · 11h 35m left. Bambu's per-tick AMS push only carries thedry_timecountdown — the chosen filament name and target temperature are never echoed on the wire, so the badge had no source of truth for them.BambuMQTTClient.send_drying_command(mode=1, ...)now caches{ams_id: {filament, temp}}on the client; the cache is cleared onmode=0and on the per-AMSdry_timefalling-edge to 0 (same detector that drives the smart-plug-after-drying callback).PrinterManager.get_drying_targets(printer_id)exposes it,printer_state_to_dictandroutes/printers.py::get_printer_statusthread it onto each AMS dict asdry_target_temp+dry_filament, the AMS schema gains both fields, and the AMS-HT compact badge gets the same render. Falls back to the first loaded tray'stray_type+ RFID-recommendeddrying_tempwhen no cached target (drying started before backend launch, backend restarted mid-cycle, or cycle started from another source) — the same heuristic the popover already uses to seed defaults. New i18n keyprinters.drying.targetSummary={{filament}} @ {{temp}}°C, translated in all 11 locales (parity check 5356 leaves per locale). 5 new backend tests inTestSupportsDryingCommand(cache populated on mode=1, overwrite on second start, cleared on mode=0, per-AMS isolation across stop) and 4 new tests inTestDryingTargetExposure(cached target wins over fallback, fallback derives from loaded tray, both fields None when no cache + empty trays, targets don't leak across AMS ids). Note about Bambu's printer display. A user reported that with PLA loaded in AMS-A slot 1 and a Bambuddy-initiated PETG @ 65°C drying cycle, the H2D's own screen showed "PLA" — Bambuddy's wire payload was confirmed correct via journalctl (filament: "PETG"sent,result: success, filament: PETG, temp: 65ACKed back). The display behaviour is the Bambu firmware labelling the active cycle by the loaded tray's filament rather than thefilamentfield of the command. This Bambuddy change makes our own UI reflect what we actually sent, independent of the firmware's display choice. - Continue auto-drying while a print is running on capable hardware — Bambu shipped "Print While Drying" firmware-side on H2D (01.03.00.00+), H2C / H2S / P2S / H2D Pro (01.02.00.00+), X2D / A2L (01.01.00.00+), and X1C (01.11.02.00+). The existing Queue Auto-Drying loop only fires on idle printers — when a print starts, drying stops or never starts, even though the spools may still be wet. New Settings → Print Queue → "Continue drying while printing" toggle (default OFF) lets the same scheduler evaluator also run on the busy printer set. Backend:
supports_drying_while_printing(model, firmware)inprinter_manager.pyis a strict allowlist verified against Bambu's wiki release-notes phrasing ("printing while filament is drying" / "Print While Drying" — every matrix-confirmed model carries that wording verbatim; P1P / P1S / A1 / A1 Mini / X1 (non-C) / X1E are intentionally excluded because the wiki is silent for them, and on those models the firmware would reject the command anyway viadry_sf_reason=[0](TaskOccupied)). The capability is gated on both display names ("H2D","X1C", ...) and internal SSDP / MQTT model codes ("O1D","O1E","O2D","O1C","O1C2","O1S","N6","BL-P001","N7","N9") — the printer'smodelfield can carry either, the existingsupports_dryingprecedent uses both._check_auto_dryinginprint_scheduler.pynow resolves model + firmware up front for every printer and computesmid_print = busy AND toggle_on AND supports_drying_while_printing; whenmid_printis True the busy-skip, queue-only-skip, and idle-skip gates are bypassed and the existing humidity /dry_sf_reason/ drying-presets / mode-1 send path takes over. Safety: drying temp is capped atmax(40, preset_temp - 5)for mid-print drying — Bambu's own release notes for H2D and P2S spell out "Lower drying temperature during printing" / "The drying temperature must not exceed the filament's softening temperature", so a 5 degC offset from the idle preset (floor 40) protects spools inside a hot enclosure during an active print. The early-return guard that short-circuits the evaluator when "only queue mode is on AND nothing scheduled" was also extended to skip the short-circuit whenprint_drying_enabledis on — otherwise busy printers would never be reached. The manual drying button on the AMS card needs no UI change:routes/printers.py::start_dryinghas no Bambuddy-sideis_idlegate; the "printer busy" rejection comes from firmwaredry_sf_reason=[0], which simply won't appear on supported firmware mid-print. The new capability flag is also surfaced onPrinterStatus.supports_drying_while_printingso the frontend can light up the AMS card affordances correctly. Settings. Newprint_drying_enabled: bool = Falseinschemas/settings.py, added to the boolean allowlist inroutes/settings.py(_BOOL_KEYS), and threaded through the existing dirty-detection / save call inSettingsPage.tsx. i18n. 2 new keys (settings.printDryingEnabled,settings.printDryingEnabledDescription) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5354 leaves per locale, no English fallback. Tests. 7 new cases inTestSupportsDryingWhilePrintingcover every supported display name + internal code, below-min firmware, excluded models (P1*,A1,A1 MINI,X1,X1E), missing firmware,Nonemodel, case-insensitivity, and the strict unknown-model default (False — unlikesupports_dryingwhich leniently allows unknowns). 4 new scheduler integration cases inTestMidPrintDryingcover: toggle ON + capable hardware fires drying at the 40 degC cap for PLA, PETG caps to 60, toggle OFF still skips busy printers, and toggle ON with too-old firmware / excluded model still skips. Fullpytest -n 30green (4251/4251 in 49 s). Backendruffclean. Frontendnpm run buildclean. Scope. No DB migration. No new permission. The new toggle is opt-in (default OFF) — existing installs see no behaviour change until a user enables it, and the firmware is the ultimate arbiter viadry_sf_reasonso being too permissive here costs nothing. - Batch / mass edit on the Filament tab (#1795, requested by @RoBoT24-web) — Bulk operations land on the Inventory page in both built-in and Spoolman modes. Reporter wanted "ten of the same spool, set a pressure advance value, save once" — the existing flow forced ten round-trips through the per-spool editor. Frontend. A new checkbox column anchors the leftmost slot of every row in the table view (header checkbox toggles every visible row; group rows expose a single checkbox that selects every member). As soon as one row is selected, a sticky toolbar appears above the list with Edit / Print labels / Reset usage / Archive (or Restore in the Archived tab) / Delete / Clear selection. The selection clears automatically on any filter or tab change so the toolbar count can never drift from what's on screen. A new
BulkEditSpoolsModalis the entry point for the bulk-edit action: a three-state-per-field form (untouched / set-to-value) over the flat spool attributes — material, subtype, brand, color name + RGBA, storage location, slicer filament name + ID, cost / kg, note, label weight, core weight, category, low-stock threshold %. The reporter's pressure-advance use case (K-profile) stays per-spool because K-profiles are scoped per(printer, extruder, nozzle_diameter)and bulk-applying a single K-value across heterogeneous printers would create wrong calibration — they're handled in the existing per-spool K-profile editor instead. Clearing fields in bulk is intentionally NOT supported (user decision on #1795): bulk-set lets you only WRITE non-empty values; emptying ten notes by mistake is a one-click disaster the dialog doesn't expose. The per-spool editor remains the path for clearing. Same dropdown controls the per-spool editor uses. Material, sub-type, brand, category, slicer preset name, and slicer filament are all rendered through a newSearchableSelectcomponent matching the per-spool form's pattern (text input + chevron + filtered list of buttons, click-outside + Escape close). No native<select>anywhere in the modal. Material / sub-type / brand options merge the canonicalMATERIALS/KNOWN_VARIANTS/DEFAULT_BRANDSconstants fromspool-form/constants.tswith whatever's already in inventory. Slicer-preset dropdowns fetch the same sources as the per-spool form (Bambu Cloud presets when signed in, Orca Cloud profiles, local presets, built-in filaments) via threeuseQuerycalls gated onisOpenso closed modal pays no fetch cost; results pipe through the sharedbuildFilamentOptions(...)helper so the option list is byte-identical to what the per-spool editor shows. Storage location is asearchableClosedSearchableSelect over actualapi.getLocations()rows mapped tolocation_id(the FK), matching the per-spool form's behaviour (rather than the legacy free-textstorage_locationcolumn, which would have written to a different column than the per-spool editor). Backend. Four new endpoints per inventory mode, eight total:POST /api/v1/inventory/spools/bulk-update,bulk-delete,bulk-archive,bulk-restore(built-in) and the matching/api/v1/spoolman/inventory/spools/bulk-*(Spoolman). All gated on the existingINVENTORY_UPDATE/FILAMENTS_UPDATEpermissions used by the per-spool routes. The built-in update endpoint runs the sameprepare_internal_spool_payload(...)path as the per-spool PATCH (location resolution, weight-lock auto-stamp on explicitweight_used— both inherited identically). The Spoolman update endpoint loops the existing per-spoolupdate_spoolroute function so the complex filament re-linking / extra-dict / extra-lock / shared-filament-detection rules stay byte-identical to single-spool edits — the bulk route is just a fan-out, not a parallel reimplementation. Per-spool failures inside the loop are collected and returned as{updated, errors: [{id, status, detail}]}so one bad ID never aborts the batch. The built-in archive endpoint reports{archived, already_archived, not_found}so the UI can distinguish "no-op because already archived" from "missing row." Both modes broadcast a singleinventory_changedWS event at the end of the batch instead of one per row, so the table refresh is a single re-fetch. Spoolman bulk-delete / archive / restore now also catch non-HTTPException mid-batch — earlier these three caught onlyHTTPException; a mid-batchhttpx.ConnectError/TimeoutError/KeyErroraborted the route with a 500, the loop's accumulated state was lost, and theinventory_changedbroadcast was skipped so the table didn't refresh past the partial state.bulk_update_spoolsgot this right out the gate; the audit pass added the sameexcept Exceptionarm to the other three so a transient Spoolman blip surfaces in the per-row errors array instead of obliterating the whole batch. All-failed and partial-failure are surfaced to the user. The first cut of the fouronSuccessmutation handlers only read the success count, so a response of{updated: 0, errors: [50 entries]}(e.g. every selected ID was deleted by another user before the click landed) showed a green "0 spools updated" toast and silently cleared the selection. The handlers now branch on three outcomes — all-succeeded (existing success toast), partial ({ok, failed}warning toast), and all-failed (red error toast + selection preserved + modal stays open so the user can retry). Same shape for delete / archive / restore.bulkResetConsumedCounterMutation.onSuccessnow closes the confirm modal + clears the selection — earlier inconsistency with the other three bulk mutations left the confirm dialog open after the action. Invalid RGBA hex is now flagged inline instead of being silently dropped from the patch. Typing "RED" or "FF00" in the colour field now paints the input red with helper text and disables the Apply button via a newhasDroppedTickedFieldguard that detects any ticked field whose value gets normalised away — without this guard the user clicked Apply, the rgba was silently omitted, and the success toast still fired for the other fields. Backend tests. 17 new integration cases. 10 intest_inventory_bulk.pycovering update applying to multiple rows, unknown IDs reported innot_found, empty update body rejected with 400, weight-lock auto-stamp parity with per-spool PATCH, emptyidsrejected with 422, bulk delete with mixed valid/invalid IDs, archive settingarchived_aton multiple rows + skipping already-archived, restore the symmetric inverse. 7 intest_spoolman_inventory_bulk.pycovering the Spoolman update callingupdate_spool_fullonce per ID with the same payload, per-spool exception collected without aborting the batch (404 on one ID + 2 successes returns{updated: 2, errors: [{id, status: 404, ...}]}), empty update rejected, emptyidsrejected, bulk delete fan-out, bulk archive callingset_spool_archived(spool_id, archived=True)for each ID, bulk restore the inverse. Fullpytest -n 30green (6384/6384 in 68 s). Frontend behaviour. Selection state is per-page-session — leaving the Inventory tab and coming back clears the set, mirroring the existing label-printer scope. The action toolbar collapses into the existingConfirmModalfor destructive operations (Delete isvariant: 'danger'; Archive / Restore / Reset usage are'warning'). Errors surface via the existinguseToast. API client. AddedbulkUpdateSpools / bulkDeleteSpools / bulkArchiveSpools / bulkRestoreSpoolsand the fourbulkXSpoolmanInventorySpoolsequivalents — matches the per-mode pattern already used forbulkResetSpoolConsumedCounter. i18n. 42 new keys under the newinventory.bulk.*namespace (33 toolbar / modal / confirm + 4 partial-failure toasts × 4 actions + invalid-hex inline helper + 1 useCustom autocomplete affordance), translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5345 leaves per locale, no English fallback. Scope. No DB migration. No new permission. SQLite + Postgres parity verified — the bulk endpoints use the same model + ORM paths as the per-spool routes. Grid (card) view does NOT get checkboxes in this drop — the reporter explicitly requested the Filament-tab list (table view); adding card checkboxes can ship as a follow-up if asked. - Spoolman weight tracking for no-3MF "Untitled" prints (#1820, requested by @ojimpo) — Closes a long-standing parity gap between Bambuddy's two inventory modes. When a Bambu print starts that Bambuddy can't fetch a
.gcode.3mffor — typically an unsaved BambuStudio project, where the printer reportssubtask_name: 名称未設定("Untitled") and FTP returns 550 for every candidate path — the existing flow created a fallback archive but Spoolman saw no weight change for that print. The internal-inventory side already handles this via the Path 2 AMS remain%-delta fallback inusage_tracker.on_print_complete(line 517). Spoolman now mirrors the same shape.store_print_datanow capturestray_remain_start(per-slotremain%+tray_uuidat print start) on every print — keyed"<ams_id>-<tray_id>", slots with invalidremain(e.g. -1, AMS hasn't read the spool yet) silently dropped, VT external trays encoded asams_id=255to match internal — and no longer early-returns when the 3MF is missing: it creates anActivePrintSpoolmanrow withfilament_usage=Nonecarrying only the snapshot, so the completion path has something to work with.report_usagekeeps its 3MF path as the primary writer and adds_report_remain_delta_for_slotsfor any slot the 3MF path didn't cover (no-3MF entirely OR partial coverage where slice_info omitted a slot). The fallback resolves each slot to its Spoolman spool via the existingspoolman_slot_assignmentstable, looks up the curatedFilament.weightfrom the spool's filament record, and writes(start_remain - current_remain) × weight / 100grams viaclient.use_spool(...). Notray_weightfrom MQTT — the failure mode #1119 documented (non-RFID spools have no MQTTtray_weight, so remain% × tray_weight gave garbage and silently mis-tracked) is dodged the same way internal inventory dodges it: by reading the user-curated reference weight from the inventory store rather than trusting MQTT's raw field. RFID gate not needed — Spoolman's curatedFilament.weightis present for RFID and non-RFID spools alike. Mid-print spool swap detection — whentray_uuiddiffers between start snapshot and completion read, the slot is skipped rather than mis-attributed. We don't know how much of the print went to which spool; preserving correctness is better than guessing. Double-charge guard — slots already written by the 3MF path land in ahandled_global_tray_idsset that the fallback consults before charging, so a 3MF-covered slot can't also pick up a remain delta. #1119 invariant preserved — the deprecated AMS-remain%-based GLOBAL writer is still gone. This is per-slot, per-print, gated on a valid start/currentremainAND a resolvable Spoolman spool. No new setting, no toggle: the parity rule feedback_inventory_modes_parity applies — same shape as internal inventory, which is unconditional. No-op default — installs with no Spoolman slot assignments, no RFID-readable AMS, or no print-time remain% (printer offline at start, AMS still loading) see no behaviour change. DB.active_print_spoolmangets a new nullabletray_remain_start TEXTcolumn via_safe_execute(ALTER TABLE … ADD COLUMN), and the existingfilament_usage TEXT NOT NULLis relaxed to nullable — for SQLite viawritable_schema = ON+sqlite_masterpatch +schema_versionbump (same surgical pattern used forusers.password_hashNULL relaxation a few hundred lines below), for Postgres viaALTER COLUMN … DROP NOT NULL. SQLite + Postgres parity verified. CREATE TABLE updated to emit the new shape on fresh installs. Tests. 11 new unit cases intest_spoolman_no3mf_remain_fallback.py: 5 for_snapshot_tray_remain(valid remain captured, invalid remain skipped, VT tray encoding, empty raw_data, missing uuid defaulted to ""), 3 forstore_print_datano-3MF behaviour (row created with snapshot when no 3MF + valid remain; no row when neither 3MF nor remain; 3MF path also captures snapshot for partial-coverage fallback), 3 forreport_usageremain-delta (writes(start-end) × Filament.weight / 100to resolved spool; skips swapped spool whentray_uuidchanged; skips slots already handled by 3MF). Fullpytest -n 30green on the Spoolman + tracking + archive + on-print suites (1205/1205). Backendruffclean. - NTP-gate state exposed on the appliance endpoint —
GET /api/v1/system/appliancegains atime_syncedfield returning"ok","warning", ornull. Source:/run/bambuddy/time-synced, written by the appliance'sntp-gate.shonce chronyd reports sync (or after a 3-minute timeout with a"warning"marker). The RPi 5 has no battery-backed RTC, so on a fresh boot the system clock is wrong until NTP catches up — JWT expiries and TLS certificate validity windows depend on this being right. Newbackend/app/core/local_config.py::read_ntp_gateis defensive on every failure mode (file absent →None, OSError →None+ warning log, empty / unknown content →None, binary garbage survives viaerrors="replace"). The endpoint stays no-auth; the SPA can use the field to render a "time not synced" badge on a fresh appliance before swapping to normal status once"ok"comes through. 8 new unit cases forread_ntp_gate(absent / ok / warning-suffixed / warning-only / empty / unknown-marker / leading-whitespace / binary-garbage) and 3 new integration cases for the endpoint field (ok / warning / absent). On Docker / manual installs the gate file doesn't exist so this is a no-op (time_syncedisnull) — the appliance is the only consumer for now. - Appliance locale defaults endpoint —
GET /api/v1/system/appliancereturns the hostname/timezone/locale the Bambuddy Appliance setup wizard collects into/etc/bambuddy/local.tomlduring firstboot. Newbackend/app/core/local_config.py::read_local_tomlparses the file defensively (missing file → empty dict, invalid TOML → empty dict + warning, non-string values dropped with a warning), so a malformed file never blocks startup. Endpoint returns{hostname, timezone, locale}withnullfor any field not present, requires no auth (the frontend i18n bootstrap fetches it before auth might be set up, and the contents are user-set defaults, not secrets). On the frontend,i18n/index.tsruns a one-shotapplyApplianceLocale()hook after init: gated by abambuddy_appliance_locale_consumedlocalStorage flag so it runs exactly once per appliance, fetches the endpoint, andi18n.changeLanguage(...)s if the returned locale is in the supported set. Non-appliance installs (Docker, manual) silently no-op when the file or endpoint is absent. The appliance writes the file via its setup wizard (separate repo:bambuddy-appliance); this PR closes the loop for the locale field — hostname and timezone are still applied by the appliance's firstboot.sh viahostnamectl/timedatectland don't need a main-app reader. Backend test coverage: 9 unit cases for the reader (missing/empty/comment-only/full/partial/invalid/non-string/unknown-keys/escaped-quotes), 4 integration cases for the endpoint (nulls when no file, full values, partial values, no-auth-required). - Unified print dispatch through the queue scheduler (#1625, by @EdwardChamberlain) — Every print Bambuddy starts now goes through the print queue's scheduler rather than the standalone
background_dispatch.pypath that previously ran in parallel for File Manager prints, archive reprints, and printer-card upload-and-print. Same end-state (a print on the printer), one code path. Effect on users: every print is now queueable, cancellable, visible on the queue page, attributable to the user that started it, and runs through the existing filament-deficit check and print-queue ownership model. The "stealth print" that didn't show up in the queue because it bypassed the scheduler is gone. Architecture. File Manager Print, archive Reprint, and printer-card upload-and-print all now POST to the existing queue routes (POST /api/v1/queue/itemswith an immediate ASAP scheduled_time) — the scheduler picks it up on the next tick and runs the same dispatch path the existing queue used to. The retiredbackground_dispatch.pyroute +services/background_dispatch.pyworker + their two test files are removed. The scheduler already had every featurebackground_dispatchdid (per-printer locking, status broadcast, error path) plus the deficit / ownership / queue-position machinery, so this is consolidation rather than a rewrite. Permission scope changes. Documented in the Security section below (#1625 introduced thequeue:createrequirement on File Manager / archive reprint / upload-and-print). i18n. Newqueue.actions.startPrintkey added across all 11 locales (the FileManagerPage button's accessible-name on the new path). Parity check holds. Tests. Allbackground_dispatchtest files removed (the routes they covered no longer exist);test_dispatch_force_timelapse.py,test_scheduler_force_timelapse_wiring.py, andtest_cleanup_forced_timelapse.pyconsolidated onto the scheduler sinceforce_timelapsenow lives there exclusively. Followup #1625-followup (this drop, listed under Fixed below) caught three issues from the post-merge audit — ownership gate mismatch on/queue/{id}/startand/queue/{id}/stop, an ASAP TOCTOU race on empty-scope inserts, and a missing duplicate-position validator on/queue/reorder— none of which were introduced by this PR but all of which became more impactful once every print routed through the queue. Scope. No DB migration. No new permission (queue:createalready existed; this PR widens its surface). No frontend behaviour change for users with full permissions — the queue surface absorbs prints that previously skipped it. - HMS error actions — Resume / Stop / Check Assistant from the dashboard (#1743, by @Ichicoro, requested in #1419 by @Ichicoro) — Bambu's HMS error dialog goes from read-only to actionable. The error modal on the printer card now renders the same Resume / Stop / Continue / Retry / Check Assistant / Don't Remind Me etc. buttons that BambuStudio and Bambu Handy show, and each click sends the matching MQTT command back to the printer. Closes the long-standing UX gap that forced users to physically walk to the printer (or open Bambu Handy) just to acknowledge a paused print. Data source. A bundled
backend/app/data/hms_actions.jsonmaps every known printer-model + error code to its list of allowable actions; populated from Bambu's publice.bambulab.com/hms/GetActionImage.phpendpoint viascripts/update_hms_actions.py. The action-ID-to-name mapping (RESUME_PRINTING, CHECK_ASSISTANT, FILAMENT_EXTRUDED, …) matches BambuStudio's open-source enum verbatim — including theCANCLEtypo, kept on purpose because Bambu's catalog spells it that way and silently fixing it would break the lookup. Backend. Newbackend/app/services/hms_actions.pydefines anHMSActionStrEnumandget_actions_for_error_code(device, error_code)lookup; loaded once at module import viaPath(__file__).resolve().parent.parent / "data" / ...so the JSON resolves regardless of CWD (systemd unit, Docker entrypoint, pytest frombackend/).BambuMQTTClient._parse_datalooks up the action list at HMS-parse time on both error sources — the structuredhms[]branch and the per-printprint_errorshort-code branch — and attaches it toHMSError.actionstogether with ajob_idsnapshot fromself.state.subtask_idso the action survives a subsequent job change. NewPOST /api/v1/printers/{id}/hms/execute-actionroute (HmsActionBodyschema; permissionPRINTERS_CONTROL) dispatches the click. Dispatcher (BambuMQTTClient.execute_hms_action). Amatchstatement maps eachHMSActionto its MQTT command —resume/stopwitherr+param=reserve+job_idfor the HMS-aware actions;idle_ignorewithtype=0(one-time) vstype=1(persistent) so Bambu's "Don't Remind Me" / "No Reminder Next Time" actually disable the warning across prints;ams_controlwithparam=done/resume/abortfor filament-load dialogs; bareclean_print_error(matches the existingclear_hms_errorsshape — no leakedprint_errorbody field);clean_print_error+uiopchained forDBL_CHECK_OK;refresh_nozzle,buzzer_ctrl mode=0(fire alarm),auto_stop_ams_dry,close_air_filtfor the standalone actions. UI-only actions (CHECK_ASSISTANT,JUMP_TO_LIVEVIEW,OK_JUMP_RACK,REMOVE_CLOSE_BTN,LOAD_VIRTUAL_TRAY,CANCLE,DBL_CHECK_CANCEL) intentionally publish nothing — they exist for label parity with BambuStudio's modal where the printer's own screen drives them. Unknown actions fall through toreturn False+ warn log so the route surfaces them as 4xx rather than silently no-opping. Every command pairs with apushing.pushallecho so the state stream refreshes on the next tick and the modal closes correctly. Schema hardening.HmsActionBody.print_errorvalidated asmin/max_length=8+pattern=r"^[0-9A-Fa-f]{8}$";actionandjob_idlength-capped. Stray input can't reach the dispatcher'smatch. Frontend.HMSErrorModal.tsxrenders a wrap-flex row of buttons under each error description, sized to fit on the printer-card panel without overflowing on narrow viewports. The mutation calls the new endpoint, invalidates the printerStatus query, and shows the newhmsErrors.actionSuccess/hmsErrors.actionFailedtoast. The button label is the translated action name fromhmsErrors.actions.<ACTION_NAME>— never the raw enum — so a forgotten translation falls back to the English action name rather than a key string. i18n. 33 action labels + 2 toast keys translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). No English fallback per feedback_translate_dont_fallback. Parity check 5381 leaves per locale. Tests. 31 new cases intest_hms_actions.py— 5 catalog-lookup (known A1 error returns actions; unknown device → empty; unknown error → empty; underscore-form0300_8070doesn't match the catalog's no-separator key; enum StrEnum value contract holds including theCANCLEtypo) and 26 dispatcher cases that pin everyHMSActionbranch to its exact MQTT payload — resume carries err+param+job_id;IGNORE_RESUME/NO_REMINDER_NEXT_TIMEuseidle_ignore type=0;IGNORE_NO_REMINDER_NEXT_TIME/DONT_REMIND_NEXT_TIMEusetype=1(persistent variants — were incorrectly bucketed together in an earlier draft);clean_print_errorbody is bare;DBL_CHECK_OKchains clean + uiop_close; uiop'serris the already-string short code (notf"{x:08X}"against a str, which would TypeError); plain resume vs HMS-aware resume distinguished; UI-only actions publish nothing; unknown action returns False;pushing.pushallecho fires after every command. Fullpytest -n 30green (6471/6471). Backendruffclean. Frontendnpm run buildclean. Scope. No DB migration. No new permission. The HMS modal is opt-in by user click — installs with no HMS errors see no behaviour change. The 9009-line catalog is shipped as a single static JSON; regenerating it later is apython scripts/update_hms_actions.pyrun away. - Inline finish-photo embed in failure-event emails +
user_print_*template disambiguation (#1792, reported by @elit3ge) — Two related changes to the notification stack. (1) Template-driven inline finish-photo in email. Pushover / Telegram / Discord / ntfy users already get the finish-photo JPEG attached to terminal-print notifications (print_complete/print_failed/print_stoppedevent types), thanks to the capture path shipped in 0.2.5b1 (#1397) that extracts the last timelapse frame at print end and loads up to 2.5 MB intoarchive_data["image_data"]. Email was the one provider that dropped those bytes on the floor — text-only body, no visual context for the reporter's "Reason: unknown" failure mails.notification_service._send_email(backend/app/services/notification_service.py:413) now acceptsfinish_photo_urlalongsideimage_dataand the dispatcher (_send_to_providerat:745) threads the URL from the rendered template variables dict. Inline embed is opt-in via the existing{finish_photo_url}template variable — first draft of this fix unconditionally inlined the photo whenever bytes were present, which @maziggy correctly flagged as bypassing the template system ("standard is to have variables for all available items in a template"). The contract now: if the user puts{finish_photo_url}in their email template body, the URL substring in the rendered body triggers the multipart/related shape — HTML part replaces the escaped URL in-place with<img src="cid:bambuddy-finish-photo">(so the image appears WHERE the variable was, not stapled to the bottom), plain-text part keeps the URL as a clickable link, MIMEImage attached inline withContent-ID: <bambuddy-finish-photo>per RFC 2392. If the template doesn't reference the variable, single-part text-only — no surprise image. Default templates are unchanged; reporter (and any user who wants this) edits theirprint_complete/print_failed/print_stoppedbody once to add the variable. XSS hygiene: rendered body ishtml.escaped before the URL→<img>swap, newlines become<br>. Pushover/Telegram/Discord/ntfy senders untouched — their pre-existing "auto-attach wheneverimage_datais set" behaviour stays because their bodies aren't HTML-templatable for inline images anyway. (2)user_print_*template names get an " Email" suffix. Same reporter surfaced a separate confusion: the Message Templates list showed "Print Completed" and "User Print Completed" side-by-side with no cue they're different dispatch paths — the first is a provider-level broadcast to whatever notification channels the admin configured (ntfy/pushover/telegram/discord/email/webhook/homeassistant), the second is a per-user SMTP-only email to the user who submitted the job (requires advanced auth +user_notifications_enabledtoggle + user has email + per-user pref opt-in). TheEVENT_NAMESdisplay map inbackend/app/api/routes/notification_templates.py:51already used the disambiguated "User Print Completed Email" label, but the seed wrote the short name to the DB, so the UI rendered the ambiguous one. Fresh installs now get the suffixed name straight fromDEFAULT_TEMPLATES(backend/app/models/notification_template.py:198+). Existing installs get the rename via a new_migrate_rename_user_print_template_names(backend/app/core/database.py:3081+) that runs on startup and updates rows for the fouruser_print_*event types WHERE the name still matches the old default — admin-edited names are preserved. Standard SQL UPDATE works on both SQLite and Postgres without dialect branching. Tests: 6 newTestEmailProvidercases inbackend/tests/unit/services/test_notification_service.pypinning the template-driven contract (no-image-no-URL → text-only, image-without-template-reference → STILL text-only, URL-in-body + bytes → multipart/related with cid, URL-arg-missing → text-only defence-in-depth, body-escape hygiene, URL→<img>in-place swap). 5 new migration cases inbackend/tests/unit/test_user_print_template_rename_migration.pycovering default-rename, user-edited preservation, provider-template don't-touch, second-run idempotency, empty-table fresh-install no-op. 11/11 + 140/140 adjacent notification tests green. Ruff clean. Verified end-to-end against a real SMTP provider with a real 48 KB finish-photo JPEG — Gmail rendered the inline image where the URL marker was in the body. - Dedicated "AI Failure Detection" notification event (#1794, reported by @maziggy from a user report) — Obico failure detection now fires its own notification event (
on_ai_failure_detection) instead of riding the multiplexedon_printer_errortoggle. Reporter (P1S, Discord provider) had Obico enabled withobico_action=notify, detection was firing correctly per the logs, every other Discord notification was working — but spaghetti detections never reached Discord. Root cause.obico_actions._notifyatobico_actions.py:75was callingnotification_service.on_printer_error(..., error_type="ai_failure_detection"). The notification service's provider filter atnotification_service.py:722-725requires the SUBSCRIBED-event boolean column to be True; theon_printer_errorcolumn defaults to False; the reporter's Discord provider was created without explicitly enabling Printer Error. The user couldn't have found the right toggle even if they'd known to look — the UI labels it "Printer Error" with no hint that flipping it also subscribes to AI detection. The same toggle multiplexed three distinct events (HMS hardware errors atmain.py:1248+ Obico spaghetti + aerror_type="ai_failure_detection"discriminator passed in the variables payload), so a user who wanted spaghetti alerts but not chamber-fan-stalled HMS pages had no way to express that. Fix. Newon_ai_failure_detectionBoolean column onnotification_providers(defaults False — matches the conservative default of every other opt-in event); newnotification_service.on_ai_failure_detection(printer_id, printer_name, task_name, confidence, action, db, image_data)method following the exact shape ofon_printer_error(mirrors variable handling, template fan-out, provider filter, fail-open under quiet-hours / digest); newai_failure_detectiontemplate entry seeded byseed_notification_templateswith variables{printer},{task_name},{confidence},{action}. The seeder only adds templates whoseevent_typeis missing, so existing installations get the new template on next start without clobbering customised ones.obico_actions._notifyswapped to the new method. Migration. Branched SQLite (DEFAULT 0) vs Postgres (DEFAULT false) per the existing stock-alert migration shape atdatabase.py:2750— Postgres rejectsDEFAULT 0for BOOLEAN columns. Existing providers receive the column with the conservative False default; they continue NOT receiving Obico notifications UNTIL they explicitly toggle the new "AI Failure Detection" event ON. This is the intended UX: previously the toggle was onPrinter Error, which the reporter had OFF, so today they get nothing; after this change they still get nothing until they opt in via the dedicated toggle, but now they can find the toggle without trial-and-error. Frontend. New toggle row inNotificationProviderCard.tsx(between Printer Error and Low Filament) with a description line "Notify when Obico AI detects a possible print failure" so users discover the link to Obico without having to read source. New summary badge ("AI Failure Detection" in fuchsia) in the collapsed card view so admins can see at a glance which providers route AI alerts. New toggle inAddNotificationModal.tsxPrinter Status section with matching state hook (onAiFailureDetection) wired through the create + update payload. ntfy per-event priority block also picks up the new event when enabled, matching how Printer Error and the stock-alert events behave there. Schema.NotificationProvidermodel +NotificationProviderBase/NotificationProviderUpdateschemas +_provider_to_dictroute serialiser + create route + PATCH route (the latter usesmodel_dump(exclude_unset=True)so it picks up the new field automatically). FrontendNotificationProvidertype + the update-payload variant. i18n. Two new keys —notifications.aiFailureDetection(label) andnotifications.aiFailureDetectionDescription(help text) — translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5242 leaves per locale, no English fallback. Tests. 4 new backend cases intest_notification_service.py::TestAIFailureDetectionNotifications(dispatch uses the new event field — NOT the legacy multiplexed one; provider with onlyon_printer_error=Trueis NOT notified — the regression guard for the reporter's symptom; variables include task_name + 2-decimal-formatted confidence + action; empty task_name falls back to "current job"). 3 new backend cases intest_obico_actions.py(execute_action(action='notify')callson_ai_failure_detectionand explicitly does NOT callon_printer_error; thepauseaction still pauses + notifies; notification-service exceptions are swallowed so a transient Discord blip can't kill the Obico detection loop). 5 new frontend cases — 4 inNotificationProviderCardAiFailureDetection.test.tsx(badge renders when ON; absent when OFF; toggle appears in expanded settings; toggling PATCHes the correct field and explicitly NOTon_printer_error) and 3 inAddNotificationModal.test.tsx(toggle renders in Printer Status section; save persists the new field without touchingon_printer_error; ntfy priority block includes the event when enabled). Existing 87test_notification_service.py+ 52 Obico tests + 65NotificationProviderCard*/AddNotificationModal*tests still green. Backendpytest -n 30clean; ruff clean;npm run buildclean; ESLint clean. Scope. No change to HMS hardware-error notifications —main.py::on_printer_errorcallers still fire theon_printer_errorevent witherror_typeshapes like"AMS Error"/"Heating Error", unchanged. Theon_printer_errorcolumn stays on the table (default False, used for HMS only). Users who had it ON for HMS errors keep getting HMS notifications; what they LOSE is silent AI-failure dispatch on the same toggle, which most users with HMS-on never received anyway becauseerror_type="ai_failure_detection"was the same valueobico_actions._notifyhardcoded. The full Obico action surface (notify/pause/pause_and_off) is unchanged on the dispatch side —execute_actionstill pauses + cuts plug power forpause_and_off; the only thing that moved is which notification-service method runs the fan-out. - Page-wide drag-and-drop upload on the File Manager (#1510, requested by @maikolscripts) — File Manager gains the same drag-and-drop upload surface that the Archives page has had: drop any file anywhere on the page and the upload modal opens pre-populated with the dropped files, no need to click the Upload Files button first. The hardcoded
"Upload 3MF"flow was the only path before this change. Unlike the Archives variant — which filters dropped files to.3mfonly — the File Manager drop zone accepts whatever the upload modal itself accepts (3MF, STL, ZIP, images), so the page-wide surface is never more restrictive than the button it shortcuts. Permission-gated onlibrary:uploadso a viewer-tier user can't accidentally trigger the overlay. Shared hook.frontend/src/hooks/usePageFileDrop.tsis the new home for the drag-handler set —isDraggingOverstate,dragHandlersto spread on the wrapper, optionalextensionsfilter, optionalonRejectedcallback for "you dropped something we won't accept" toasts,disabledflag for permission gating. Archives and File Manager both consume it; future drop-zones can opt in without re-implementing the cancel-safe logic.FileUploadModal.initialFilesprop. Modal accepts aFile[]to pre-seed itself on first mount via aseededInitialRefguard so the same files don't re-add on subsequent renders. Existing manual-open paths (Upload Files button) pass nothing and behave unchanged. i18n. New keyfileManager.releaseToUploadtranslated in all 11 locales (en: Release to upload, de: Loslassen zum Hochladen, es: Suelte para subir, fr: Relâcher pour téléverser, it: Rilascia per caricare, ja: 離してアップロード, ko: 놓아서 업로드, pt-BR: Solte para enviar, tr: Yüklemek için bırakın, zh-CN: 释放以上传, zh-TW: 釋放以上傳); existingfileManager.dropFilesHerereused. Parity 5240 leaves × 11 green, no English fallback. Tests. 13 new cases insrc/__tests__/hooks/usePageFileDrop.test.tsxcovering: overlay on dragenter, non-file payload ignored, child-element dragLeave keeps overlay (relatedTarget inside wrapper), outside-element dragLeave hides it, null relatedTarget hides it (cursor left window), document drop / dragend / Escape all reset (the three cancel paths the prior inline implementation missed — see the Fixed entry), drop with mixed file types filters by extension, onRejected fires when extension filter drops everything, disabled is a no-op, overlay clears on successful drop. Existing 85 cases across ArchivesPage / FileManagerPage / FileManagerExternalFolder vitest still green. ESLint clean;npm run buildclean. - Sort Printers page by ETA (#1609, requested by @forgecrafttechnologies-source) — The Printers page sort dropdown gains a fifth option, ETA, beside the existing Name / Status / Model / Location. Sorts the fleet by remaining print time so the printer that's finishing next sits at the top — the reporter's use case is staging the next job's filament ahead of time without scanning every card. Tier ordering. Tier 0 = currently printing with a known
remaining_time > 0, sorted ascending by remaining minutes (soonest first); Tier 1 = currently printing without an ETA yet (post-start_printwindow before the slicer reports total time); Tier 2 = idle / finished; Tier 3 = offline. Tiebreaker within every tier is printer name, so two printers with the same ETA — or two idle printers — stay in a stable alphabetic order. The ascending / descending direction button still applies after tiers resolve, so descending puts offline printers at the top for operators triaging the fleet for connectivity issues. Data source. The cachedremaining_time(minutes) on the per-printer status query (['printerStatus', id]) — the same field the per-card "ETA … min" label already reads from onPrintersPage.tsx:3633and the fleet-wide "next finish" badge already aggregates onPrintersPage.tsx:996. No new backend query, no new round-trip; the sort consumes data that's already in the React Query cache and updated on every WebSocket push. No grouping. Unlikestatus/model/locationsorts (which group rows under section headers), the ETA sort renders a flat list — each printer's ETA is unique so grouping would just produce a header per row. i18n. New keyprinters.sort.etatranslated in all 11 locales (en: ETA, de: Restzeit, es: Tiempo restante, fr: Temps restant, it: Tempo rimanente, ja: 残り時間, ko: 남은 시간, pt-BR: Tempo restante, tr: Kalan süre, zh-CN: 剩余时间, zh-TW: 剩餘時間), no English fallback. Parity check 5239 leaves per locale, green. ESLint clean;npm run buildclean. - File Manager: user-authored tags for cross-cutting file filtering (#1268, requested by @zumik3-del, seconded by @unLieb) — Third and final piece of #1268, shipped alongside the recursive-search + markdown-description-panel changes below. Folders are the hierarchy (every file lives in exactly one); tags are the orthogonal labels ("toy", "kid-safe", "petg-only", "failed twice", "gift") and a single file can carry as many as the user wants. Reporter wanted to find "every toy regardless of which folder it lives in" — folders alone can't do that without forcing files into one bucket. Catalog model. New
library_tagstable (id, name, name_key UNIQUE =LOWER(TRIM(name)), timestamps) holds the global tag catalog — one set per install, not per-user (matches the Locations PR #1505 from earlier in 0.2.5b1).name_keyUNIQUE on a normalised key collapses "Toys" / "toys" / " TOYS " into a single row so users can't accidentally fragment the tag space by typing variations. Newlibrary_file_tags(file_id, tag_id)composite-PK association table withON DELETE CASCADEon both sides — deleting a tag drops every chip from every file (files survive); deleting a file drops its tag links (catalog rows survive). Both tables auto-create viaBase.metadata.create_all()at init — no explicitrun_migrations()step needed since they're greenfield. API. New/library/tagsrouter (backend/app/api/routes/library_tags.py) withGET(list + per-tagfile_countprojected via subquery; filtered by ownership forLIBRARY_READ_OWNusers so chip counts match what they'd actually see),POST(create — strips whitespace, 409 on case-insensitive dup with both pre-check AND post-commit IntegrityError catch for race safety),PATCH /{id}(rename, same 409 rules, self-rename allowed via id-exclusion in the pre-check),DELETE /{id}(cascade), andPOST /library/tags/bulk-assignfor multi-file ops. Bulk-assign supports three actions:add(idempotent — re-applying doesn't 409, just no-ops for pre-existing pairs, count reports what actually changed),remove, andreplace(strip everything currently on the listed files, then INSERT the new set — passing emptytag_idswithreplaceclears the file's tag set entirely). Per-file ownership enforced forLIBRARY_UPDATE_OWNusers via a pre-filter onfile_ids(silently drops files the caller can't update — same posture aslibrary_trashbulk routes; the response counts reflect what actually happened so the UI can detect partial application). Unknownfile_ids(race with a deleter, stale FE selection) are silently dropped instead of 404'ing the whole call.list_filesextension. Newtag_ids: list[int]query param on the existing/library/filesroute — repeated?tag_ids=N&tag_ids=Mstyle. AND semantics: JOIN the association,GROUP BY file.id HAVING COUNT(DISTINCT tag_id) = len(tag_ids), portable across SQLite and Postgres. Per the design discussion, the tag filter intentionally bypasses folder scoping (folder_id/project_id/include_root/recursiveare all skipped whiletag_idsis non-empty) — the whole point of tags is cross-cutting "every file matching these labels regardless of where it lives". Every file in every listing response now carriestags: list[{id, name}]viaselectinload(LibraryFile.tags)so chip rendering on the FE is N+1-free. Frontend —LibraryTagsModal. Catalog CRUD modal opened from the File Manager toolbar's new Tags button, max-w-4xl wide so multi-language subtitles don't wrap. Table with name + file count + rename/delete actions; row-click pushes the tag into the active filter and closes the modal. Delete confirm-dialog warns specifically whenfile_count > 0("This tag is on N file(s). Deleting removes the chip from all of them; files themselves are untouched."). Same Esc / backdrop / mid-mutation guard shape asLocationsModal. Frontend —BulkTagsPickerModal. Opens from the File Manager's multi-select toolbar (new Tag button between Move and Delete). Add/Remove radio at the top, scrollable checkbox list of catalog tags, inline "create new tag" affordance disabled on case-insensitive dup against the existing list, Apply button disabled until ≥1 tag is selected. Thereplaceaction is exposed in the API but deliberately NOT in the UI — arbitrary multi-file replace is destructive and confusing; future bulk-edit screen can opt in later. Frontend —FileManagerPageintegration. NewselectedTagIds: number[]state, sorted into theuseQuerykey so the cache hits are stable regardless of toggle order. Tag catalog shared with the modals via['library-tags']query key (extracted tofrontend/src/utils/libraryTagsQuery.tsto satisfy Vite's react-refresh rule that component files export only components).useEffectprunesselectedTagIdswhen a tag is deleted from the catalog so the filter never strands on a phantom id. Filter rail above the file list lists EVERY catalog tag as a togglable chip — inactive chips are outlined and muted, active chips are filled bambu-green with an X, click toggles. "Clear all" appears only when ≥1 tag is active. Hidden entirely when the catalog is empty so fresh installs don't see a stray bar. List view gets a dedicated Tags column atminmax(0, 200px)between Prints and Actions — placed after the existing data attributes since tags are a "file attribute". Empty state shows a-to keep the column shape consistent. Grid view chips render below the metadata block in each FileCard. Chip clicks in both views push toselectedTagIds; click propagation is stopped so a chip click doesn't toggle the file's selection state. Type safety.LibraryFileListItem.tags?: LibraryTagSummary[]is OPTIONAL even though the backend always emits an empty array, because legacy msw mocks in pre-existing tests (FileManagerPage / FileManagerExternalFolder) construct partial file shapes without the field — without the?the renderer crashed on.length. Read sites usefile.tags ?? []and the!.non-null assertion only inside the inner&&guard. Dependencies. Zero new deps. The whole tag UI reuseslucide-react'sTagicon, existing button/modal primitives, and@tanstack/react-queryalready in the bundle. Bundle size unchanged from the previous 0.2.5b1 baseline (7,876 KB raw / 2,122 KB gzip). Tests. 15 backend integration cases inbackend/tests/integration/test_library_tags_api.py— CRUD: create + list, strip-whitespace, case-insensitive dup 409 across "Toys"/"toys"/"TOYS"/" ToYs ", rename, rename-collision 409, self-rename allowed, delete cascades associations but keeps files, delete-unknown 404. Bulk: add idempotency (second call adds 0, file_count stays 1), remove drops only listed tags (peer tag stays), replace-with-empty clears, unknown file ids silently skipped, invalid action 422. Filter: AND across two tags returns only the intersection file, tag filter overrides folder_id (file from another folder still appears when the tag matches), file listing includes the tags array. 15/15 green plus 102/102 acrosstest_library_api.py+test_library_trash_api.py(no regression). 8 frontend cases — 4 inLibraryTagsModal.test.tsx(renders + count, create flow PATCHes correctly, row click → onPickTag + close, in-use delete warning), 4 inBulkTagsPickerModal.test.tsx(lists tags, check + Add calls bulkAssign with action='add' and the right file/tag arrays, Remove radio + Apply uses action='remove', Apply disabled when no tag selected). Full vitest run: 2249/2249 across 170 test files. Full backendpytest -n 30: 6341/6341. i18n. 37 new keys underfileManager.tags.*namespace (modal title/subtitle, manage/manageTitle, add/edit, name/fileCount, empty/noMatches, createPlaceholder/createButton, nameRequired, searchPlaceholder, CRUD success/failure toasts, applyAdd/applyRemove + their success messages, actionAdd/actionRemove radio labels, tagAction button, bulkTitle, bulkTooltip, noPermission, filterLabel, clearAll, confirmDelete + the in-use variant, editAria/deleteAria). Translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5292 leaves per locale, no English fallback (thedefaultValue: "..."shortcut from a first-draft modal was removed precisely so the parity check would fail loudly if any locale missed a key). Permissions. Catalog mutations requireLIBRARY_UPDATE_ALL(the catalog is global — ownership-aware update isn't meaningful for a row no user owns). Bulk-assign uses the existingLIBRARY_UPDATE_ALL/LIBRARY_UPDATE_OWNownership pair. GET usesLIBRARY_READ_*with the file-count projection narrowed for*_OWNcallers. No new permission constants, no new RBAC migration. Out of scope (deferred to v2 if asked). Tag colors / icons (label-only chips per design decision #1), tags onprint_archivesrows (different mental model — archives are completed prints), auto-tags derived from 3MF metadata categories (kept user-authored per design decision #4), import/export of the tag set, tag-filter intersected with folder scoping (the design call was that cross-cutting filter overrides folder selection — adding an "AND folder" toggle would need separate UX work). Closes #1268 alongside the recursive-search and markdown-description-panel pieces below — all three deliverables in this issue ship in the same minor. - File Manager: recursive search and per-folder markdown description panel (#1268, requested by @zumik3-del, second by @unLieb) — Two of the three asks bundled in #1268; the third (tags) is gated on the community-interest check Martin posted there. (1) Recursive search inside the selected folder. Until now, picking "Toys" in the sidebar and typing
robotonly found files inToys/itself — anything underToys/Cars/orToys/Cars/Race/was invisible until the user manually drilled in. The page's client-side filter was running over a server-narrowed list (/library/files?folder_id=Xis strict equality onfolder_id), so search couldn't see what the listing didn't load. Newrecursive=truequery param on/library/fileswalks thelibrary_folders.parent_idtree via a recursive CTE rooted at the requestedfolder_idand returns every descendant folder's files in one round-trip. Recursive CTEs work on both SQLite (≥3.8.3, shipped 2014 — Bambuddy's floor is well above that) and Postgres without dialect branching. Default off so the existing folder-browsing call sites (Project / Archive detail pages, the FE's no-search case) keep their narrow single-folder semantics — only the FE's search bar opts in, and only when both a folder is selected ANDsearchQuery.trim()is non-empty. A small "Including subfolders" hint renders under the search input when the recursive request is active so the user understands why a file from two folders away showed up. (2) Per-folder markdown description panel. New endpointGET /library/folders/{folder_id}/readmereads the first.mdfile in the folder and returns{filename, content, truncated}. Selection prefersREADME.md/readme.md/description.md(case-insensitive — picked viafunc.lower(filename) LIKE '%.md'filter + an in-Python stem-preference sort), falls back to the alphabetically-first*.mdotherwise. 404 when no markdown file is present so the FE can hide the side panel — non-users pay no UI cost. Bytes are clipped at 512 KiB (_README_BYTES_CAP) with atruncatedflag so the panel can warn the reader; UTF-8 decode useserrors="replace"so one bad byte never blanks the panel. NewFolderReadmePanel.tsxcomponent fetches the README on folder-select, renders it viareact-markdown@9+remark-gfm@4(tables / strikethrough / task lists), collapsible (default expanded), max-height 24rem with internal scroll. react-markdown 9 doesn't render raw HTML by default — XSS safe without dompurify. Links open in a new tab withrel="noopener noreferrer". Tailwind has no typography plugin in this project so per-element components map h1/h2/h3/p/ul/ol/code/blockquote/table/etc. to explicit utility classes that match the rest of the app's look. Both ask 1 and 2 ship as one PR because they share scope (file-manager UX), the same reporter, and the same review surface; ask 3 (tags) is held back as gated on the public interest signal Martin requested in his comment ("If you'd find this feature useful, please give this issue a thumbs up"). Backend.list_filesroute atbackend/app/api/routes/library.py:1729+gains therecursive: bool = Falseparam + the recursive-CTE branch. Newget_folder_readmeroute at:1042+with_README_BYTES_CAPconstant +_README_PREFERRED_STEMSselection tuple. NewFolderReadmeResponseschema inbackend/app/schemas/library.py:66+. Frontend.api.getLibraryFilesatfrontend/src/api/client.ts:5785+gains therecursive = falseparameter;api.getLibraryFolderReadmeis the matching helper for the new endpoint.FileManagerPage.tsxderivessearchExpandsSubfoldersfromselectedFolderId !== null && searchQuery.trim().length > 0and threads it into both theuseQuerykey (so toggling search refetches with the new scope) and the API call. The newFolderReadmePanelmounts above the file list whenselectedFolderId !== null. Dependencies.react-markdown ^9+remark-gfm ^4added tofrontend/package.json(~30 KB gzipped — single use-site for now, but reusable for any future markdown surface — print-archive notes, custom-field docs, etc.). No new backend dependency. Tests. 6 backend integration cases inbackend/tests/integration/test_library_api.pypin the contract:recursive=truewalks a three-level tree and returns files from all levels but NOT a sibling unrelated branch;recursive=truewithoutfolder_idis a no-op (the existinginclude_rootbranch still handles scoping); README endpoint returns the first .md with the correct on-disk content; README endpoint prefersREADME.mdovernotes.mdeven whennotes.mdis inserted FIRST andreadme.mdis lowercase; 404 when the folder has no .md; 404 when the folder doesn't exist. 3 frontend cases insrc/__tests__/components/FolderReadmePanel.test.tsxcover: 404 hides the panel (no leaked chrome), markdown content renders viafindByRole('heading'), truncated flag surfaces a chip. Full backendpytest -n 306326/6326 green; frontend vitest 1094/1094 component cases green; ruff clean;npm run buildclean. i18n. 2 new keys —fileManager.searchSubfoldersHint(the small under-search caption) +fileManager.readme.truncated(the chip label when the markdown was clipped). Translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity check 5255 leaves per locale, no English fallback. Scope. No new permission — both endpoints reuse the existingLIBRARY_READ_ALL/LIBRARY_READ_OWNownership-aware permission pair (so a viewer-tier user withread_ownonly sees their own files in recursive listings + can only request the README of folders containing their own files). No DB migration. The recursive CTE is a single SQL query — no N+1, no per-folder round-trip, scales to deeply-nested model libraries. - By-tag spool lookup, readable with a Manage-Inventory API key (#1700 closing #1663, reported + contributed by @bambuman) — Companion to the QR-code-API-key flow below: gives @bambuman's BambuMan NFC inventory app — and any future scanner-driven Bambuddy integration — a way to dedupe a spool scan with a single, narrowly-scoped API key. New endpoint:
GET /inventory/spools/by-tag?tray_uuid=…&tag_uid=…&include_archived=false.tray_uuidis the primary identifier (it's the same 32-char hex the AMS reports over MQTT, so the scan can match a spool that's already linked to the printer),tag_uidis the fallback. At least one must be supplied (400 otherwise); 404 when nothing matches. Both values are passed throughnormalize_tray_uuid/normalize_tag_uidfrombackend/app/utils/tag_normalization.py— lowercase / colon / dash separators all match the stored uppercase hex, mirroring the existinglink_tagroute'sfunc.upper(column) == valuecomparison so SQLite and Postgres behave identically. Archived spools are excluded by default, opt in viainclude_archived=true. Why this isn't on the existing/inventory/spoolslist endpoint: that one is purely advisory — it returns every spool the caller is allowed to see, no auth narrowing possible. The contributor's NFC app would have had to pull the whole inventory to check whether a freshly-scanned tag already existed, which both required the broader Read Status scope (an API key with Manage Inventory alone — the documented kiosk/inventory-write scope — couldn't list spools) and grew O(n) with the user's spool count. By-tag lookup is O(1) and the narrower scope rule below means the Manage-Inventory key the app already needs to create a spool is also enough to check whether one exists before creating. Scope shape (per-endpoint, NOT a global mapping change):RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_UPDATE)— INVENTORY_READ is satisfied bycan_read_status(read-status keys), INVENTORY_UPDATE bycan_manage_inventory(manage-inventory keys), and_check_apikey_permissions(..., require_any=True)enforces that at least one mapped flag is set (the GHSA-r2qv-8222-hqg3 fail-closed rule). Listing all spools (/inventory/spools) and fetching by id (/inventory/spools/{id}) still require Read Status unchanged — only this one endpoint accepts either scope. The first iteration of the PR widened the global_APIKEY_SCOPE_BY_PERMISSIONto a tuple, which would have promoted ~21 inventory-read endpoints to also accept manage-inventory keys; review caught that the global shape was wider than the ask and the contributor revised to the per-endpoint dependency. The drift-detection RBAC scope-introspection tests stay untouched because the global table didn't change. Route ordering: the new/spools/by-tagregisters atinventory.py:1184before the existing/spools/{spool_id}at:1227, so FastAPI's first-match wins and the literalby-tagpath never collides with theint spool_idroute (pinned bytest_does_not_collide_with_spool_id_route). Tests: 13 integration cases inbackend/tests/integration/test_spool_by_tag_lookup.py— match by tray_uuid, match by tag_uid, normalisation of messy input, tray_uuid-preferred-when-both-given, tray_uuid-miss falls through to tag_uid (not 404), no-id → 400, non-hex → 400, no-match → 404, archived-excluded-by-default + include-archived opt-in, route-collision regression, plus three API-key scope cases that pin the new dependency (manage-inventory key reads, read-status key reads, key without either inventory scope gets 403). 13/13 green plus the 48 existing route-auth-coverage + RBAC tests still green (therequire_substring pattern already catchesrequire_any_permission_if_auth_enabled.<locals>.checker— no allowlist edit needed). Ruff clean. Companion docs (maziggy/bambuddy-wiki#42):docs/reference/api.mdgains a new Spool Inventory section documenting the endpoint contract;docs/features/api-keys.mdadds the by-tag row to the Common Endpoints table and a "Manage Inventory keys can look up spools by tag" note. No DB migration, no schema change, no frontend change. - QR code on API-key creation that encodes server URL + key together (#1677, contributed by @bambuman) — The "API Key Created Successfully" panel gets a new QR code button next to Dismiss. Clicking it opens a modal showing a single QR encoding the Bambuddy base URL and the freshly-created API key together, so a mobile client (e.g. the contributor's BambuMan NFC inventory app, or any future Bambuddy-aware app) can scan once to configure both — no copy-paste of the long, shown-only-once secret. Payload contract (versioned):
bambuddy://config?v=1&url=<encodeURIComponent(baseUrl)>&key=<encodeURIComponent(apiKey)>.v=1first so future bumps tov=2have a clean deprecation path; both values URL-encoded so reserved characters in either don't corrupt the parse. The builder lives infrontend/src/utils/apiKeyQr.tsexportingbuildApiKeyQrPayload()+API_KEY_QR_VERSIONso any future mobile-side parser has a stable shared constant to anchor against.baseUrlsource: prefers the configured External URL setting (Settings → Network), falling back towindow.location.originif not set, so the encoded address is reachable from a phone behind a reverse proxy / Docker host. The fallback's failure mode (admin onhttp://localhost:8000without External URL configured → phone can't reach the encoded URL) is unavoidable without exposing a network probe; the warning text in the modal cautions the user generally. Security posture: the QR is generated client-side from the in-memorycreatedAPIKeyReact state — the key is never persisted, never re-fetched (keys are stored hashed at/api/keysPOST and returned in plaintext exactly once), and never round-trips to the server. No download button (intentional contrast with the existingQRCodeModal.tsx, which encodes a public archive URL and does offer download) so the secret can't be saved to disk via the browser's download manager. The "Dismiss" handler now clears bothshowApiKeyQRandcreatedAPIKeyso closing the panel scrubs the plaintext from React state. Modal closes on Escape and backdrop click; an amber warning under the QR reminds the user not to screenshot or share. Component: newfrontend/src/components/ApiKeyQRCodeModal.tsxusingqrcode.react'sQRCodeSVGat 256 px (renders Version 5 / 6 territory for the typical ~120-character payload, comfortably below the alphanumeric capacity). Dependency:qrcode.react ^4.2.0added tofrontend/package.json(+21 KB raw / ~9 KB gzip to the bundle). Existingfrontend/src/components/QRCodeModal.tsxis untouched — different purpose (server-rendered PNG for archive deeplinks), different component, no collision. Tests:frontend/src/__tests__/utils/apiKeyQr.test.tspins the contract — scheme +v=first, exact encoding ofhttps://printer.local+bb_abc123byte-for-byte, special-character round-trip (+,/,=,&, spaces), explicit assertion that the raw unencoded key never leaks into the payload, and aURLSearchParamsround-trip that re-parsesv/url/keyback out and asserts equality with the inputs. 4/4 green. i18n: 4 new keys in thesettings.*namespace (apiKeyQrButton,apiKeyQrTitle,apiKeyQrCaption,apiKeyQrWarning); full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity check green. ESLint clean;npm run buildclean (7,603 kB raw, +21 kB vs dev). No backend change, no permission change, no DB migration. - Centralised sidebar layout + per-page hide toggles (#1673, contributed by @EdwardChamberlain) — Sidebar item ordering and visibility move from inline
Layout.tsxstate to a dedicated module so the same persistence rules apply whether the user is reordering with drag-and-drop, toggling an item off, or accepting the admin-pushed default. Newfrontend/src/utils/sidebarLayout.tsowns the localStorage round-trip (sidebarOrder+sidebarHiddenSystemItemskeys), theSIDEBAR_LAYOUT_CHANGED_EVENTcross-tab refresh broadcast, and theisExternalSidebarItemIdhelper that distinguishes the newext-*external link prefix from built-in nav. Hide / show toggle: every built-in sidebar entry (Printers / Inventory / Archives / Queue / Projects / File Manager / Makerworld / Profiles / Maintenance / Statistics — Settings is intentionally non-hideable) now carries an eye icon in the Sidebar settings card; click it to drop that entry from the rendered sidebar. Hidden IDs persist per-user via localStorage so personal taste survives reloads without leaking to other users on a shared install. Re-show by clicking the eye again. The previous drag-to-reorder UX is retired in this PR — the hide list + admin default order cover the same "I never use the Stats page" / "give me Files first" needs without the affordance ambiguity of the rearrange handle. Admin default order: newdefault_sidebar_ordersetting (validated server-side atbackend/app/schemas/settings.py:533+) holds a JSON object{order: string[], hiddenSystemItemIds: string[]}that admins set once from Settings → General → Sidebar (Set Default toggle). On first login per user,Layout.tsx'suseEffectreads the admin default, filters it against the currentdefaultNavItems+ valid external IDs (so a deleted external link or a removed built-in doesn't strand in someone's stored order), applies it locally, and records a per-usersidebarDefaultApplied_<user_id>localStorage flag so the default is one-shot — later user-driven changes aren't clobbered on every login. Settings card:ExternalLinksSettings.tsxis the single source of truth for the Sidebar card (card-sidebar-links) in Settings → General. The header now carries the Set Default toggle (visible only when the caller holdssettings:write), a Reset button (clears bothsidebarOrder+sidebarHiddenSystemItemsto defaults), and the Add Link button (opens the external-link create modal). The body lists every sidebar item — built-in or external — with the eye toggle inline on each row. The header row usesflex-wrapon the outer container and the right-side control group so the Add Link button doesn't overflow the card's right edge when Column 3 sits at its narrowlg:max-w-sm(384px) width. Settings → General reordering (post-merge polish): the Updates card moved to the top of Column 3 (above the new Sidebar card); the Data Management card moved to the bottom of Column 2 (after Library Auto-Purge) so the General tab balances better with the new Sidebar card taking column 3's vertical real estate. Anchor IDscard-updates,card-data,card-sidebar-linksare preserved so deep-links + the in-appregisterSettingsSearchindex still resolve. Layout merge edge case: the PR's refactor ofLayout.tsx::isHiddenaccidentally dropped the dev-side notifications gate (!authEnabled || !advancedAuthStatus?.advanced_auth_enabled || settings?.user_notifications_enabled === false) and itsadvancedAuthStatususeQuery. The merged shape keeps three gates in priority order —hiddenSystemItemIds.includes(id)first (cheapest, explicit user intent), then the array-awarenavPermissionscheck from #1755 (granular*:read_own/*:read_alltiers), then the notifications-specific gate — so a user without advanced auth doesn't suddenly see the Notifications entry. Backend:default_sidebar_ordersettings field accepts both shapes (plain array OR{order, hiddenSystemItemIds}object) for backward compat with installs that saved an array under an earlier draft of this work. Validator rejects anyhiddenSystemItemIdsthat isn't alist[str]with 422. Tests: 17 new backend cases intest_sidebar_settings.pypinning the validator (empty / JSON-array / JSON-object / mixed-types / hostile shapes). Frontend: 5 newLayout.test.tsxcases pinning the hide-toggle behaviour (hidden ID drops the entry, hidden ID for Settings is ignored —settingsis non-hideable, eye-click round-trips through localStorage,SIDEBAR_LAYOUT_CHANGED_EVENTtriggers a re-read across tabs) and 255 added/changed lines inSettingsPage.test.tsxcovering the admin-default toggle and the eye-icon visibility column. i18n: new keys in theexternalLinks.*namespace (sidebarLayout / sidebarLayoutDescription / visibleInSidebar / hiddenFromSidebar / requiredInSidebar / setDefault / etc.), full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5168 leaves per locale. Vitest test timeout raised invitest.config.tsto absorb theuserEvent.setup({delay: null})cases in the heavierSettingsPageflows. Full vitest run green; ESLint clean;npm run buildclean; ruff clean. - Structured storage locations catalog (#1505 closing #1004, contributed by @Poltavtcev) — Inventory gets a first-class catalog of physical storage spots (shelves, drawers, dryboxes) instead of free-text in the spool's
storage_locationfield. Spools now carry alocation_idFK alongside the denormalizedstorage_locationstring (kept for Spoolman wire format + label rendering). The Inventory page picks up a Locations button that opens an in-page modal — the original PR landed a standalone/inventory/locationspage; merged shape is a modal opened from Inventory so the catalog read sits next to the spool list. The modal handles create / edit / delete / pick-to-filter; row-click pushes the location_id into the Inventory filter state without a navigation. Deep-link?location_id=<n>(and?location_id=__none__for the unset bucket) still works for sharing or bookmarking. Backend: newLocationmodel +locationstable with case-insensitivename_key(LOWER(TRIM(name))) UNIQUE — concurrent creates on the same name resolve to a single 409 via theIntegrityError→ re-fetch shape in_create_location_or_get_existing. CRUD at/api/v1/inventory/locations, all five routes gated withRequirePermissionIfAuthEnabled(Permission.INVENTORY_READ|UPDATE). Delete is blocked whilespool_count > 0so the user can't strand spools. Single-write-path islocation_service::resolve_spool_location_fields()— both the internal-mode and Spoolman-mode spool routes feed through it solocation_idandstorage_locationcan never drift. Spoolman parity: location names sync into the local catalog onGET /spoolman/inventory/spoolsviamaybe_sync_spoolman_locations; rename cascades to every Spoolman spool viaclient.rename_location, with a per-spool PATCH fallback when the upstream's bulk endpoint isn't there (Spoolman <0.16 doesn't exposePATCH /location/{name}and returns 404/405).get_distinct_locationsnormalises both the olderlist[str]and the newerlist[dict]Spoolman payload shapes. Migration: inline indatabase.py::run_migrations— creates thelocationstable (DATETIME for SQLite / TIMESTAMP for Postgres), addsspool.location_idFK + index, then backfills the catalog from existing free-text values (GROUP BYLOWER(TRIM(storage_location))so case variants likeDrybox 1andDRYBOX 1collapse into one row). The legacyname_keybackfill runs BEFORE the dedup INSERT so a pre-existing locations row with NULLname_key(manually inserted before this feature shipped) gets its column populated first and the subsequent spool-link UPDATE can join on it. Post-migration warn-log flags any spools that still carry free-textstorage_locationwith nolocation_id— surfaces the rare mis-link case to ops instead of silently leaving them out of catalog filters. Rename safety: Spoolman PATCH runs BEFOREdb.commit(), cascade failure rolls back the local rename and raises HTTP 502 — without this ordering a partial failure left the catalog and Spoolman's per-spoollocationfield permanently diverged (the next sync recreates the old name as a duplicate catalog row). Legacy-row UPDATE matchesfunc.lower(func.trim(Spool.storage_location)) == old_name.strip().lower()so the SQL TRIM symmetry holds for whitespace-padded values. Cross-tab refresh:spoolman_inventory.pynow emitsinventory_changedon the 8 spool-mutating routes (create, bulk-create, update, delete, archive, restore, reset-bulk, weight, tag) — internal mode already broadcast in 12 places, Spoolman mode silently degraded before. TheuseWebSockethandler invalidatesinventoryLocationsQueryKeyon every such message so location counts stay in sync across tabs. Performance: the Spoolman→catalog sync used to fire on everyGET /spoolsrequest, hit Spoolman, and open a write transaction; now guarded by a 60s per-URL TTL cache (_spoolman_location_sync_last_run) so a polling UI doesn't burn a Spoolman round-trip + SQLite write per refetch. The route also passes its already-resolved client through to the sync so test fixtures that patch the route module's client also catch the sync's client lookup — without this the SSRF LAN-topology parametrize tests took ~45s on real TCP timeouts to RFC-1918 IPs (now 2.79s in isolation). Frontend:SpoolFormModallocation dropdown sendslocation_idonly (same shape in both inventory modes — nospoolmanMode ? ... : ...UI gate) and theonCreateLocationflow surfacesApiError.messageinstead of a generic toast so 409 / 400 / 500 stay distinguishable.LocationsModalpassesisLoadingtoConfirmModalduring delete so a mid-mutation cancel can't strand a toast on a dismissed dialog; Pencil / Trash icon buttons carryaria-labelfor SR announcement. i18n: newlocations.*namespace (20 keys: title, subtitle, add, edit, delete, empty, name, spools, manage, createPlaceholder, nameRequired, created, updated, deleted, saveFailed, deleteFailed, deleteBlocked, confirmDelete, confirmDeleteMessage, editAria/deleteAria), full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5168 leaves per locale. Tests: ~26 new acrossbackend/tests/unit/test_location_service.py(rename strip/lower symmetry, sync-from-Spoolman log-on-unavailable, list[dict] payload normalisation),backend/tests/unit/test_spoolman_inventory_methods.py(get_distinct_locationsshape guard × 4,rename_locationbulk-then-fallback × 4 — 200 / 404 / 405 / 5xx),backend/tests/unit/test_location_migration.py(NULL + whitespace-only storage_location skip, legacy NULL name_key ordering, case-variant dedup, idempotency),backend/tests/integration/test_locations_api.py(CRUD round-trip, rename cascade, IntegrityError → 409, PATCH/DELETE 404, auth-gate 401 on all five routes whenauth_enabled=true), andfrontend/src/__tests__/components/LocationsModal.test.tsx(12 cases: open=false renders nothing + no fetch, row click → onPickLocation + onClose, 2-level Escape dialog stacking, rename collision 409 toast, disabled delete onspool_count>0, etc.). FrontenduseWebSocket.test.tsexercises theinventory_changed→ invalidate['inventory-locations']round-trip. Full backend pytest 6025/6025 (67s with -n 30); frontend vitest 2141/2141; ruff clean;npm run buildclean; ESLint clean; i18n parity green. - Admin-configurable session lifetime (#1706, reported by @AD3DStuff) — The 24-hour session cap that ships with Bambuddy was an intentional security hardening (audit finding M-2 reduced it from 7 days), but the "Remember Me" checkbox only controlled storage location (localStorage vs sessionStorage), not session duration. iPhone PWA users and homelab admins on trusted networks were getting kicked out every 24 hours with no way to extend it. New setting:
session_max_hoursunder Settings → Users with three presets (24h / 7 days / 30 days) plus a custom field, hard-capped at 30 days (720h). Default remains 24h so existing deployments and the M-2 audit baseline are untouched until an admin opts in. The Settings card surfaces a yellow warning whenever the value exceeds 24h: "Longer sessions reduce automatic logout protection. Recommended only for trusted single-user deployments." Backend wiring: newresolve_session_max_minutes(db)helper inbackend/app/core/auth.pyreads the setting, clamps to [1h, 720h], and falls back to 24h on missing / blank / unparseable values. The helper is called at all four token-issuance sites — plain/auth/login, 2FA TOTP/email completion, 2FA backup-code completion, and OIDC callback — so a long-session policy works uniformly regardless of how the user authenticates. DB errors in the resolver are deliberately NOT caught: login is already inside a transaction and a broken DB must abort the login rather than silently extend or shrink the session lifetime. Defense-in-depthSESSION_MAX_HOURS_HARD_CEILING = 720clamps any tampered DB row above the Pydantic ceiling. Already-issued tokens keep their original expiry — the new setting only affects future logins, so an admin lowering the value can't retroactively revoke active sessions and an admin raising it can't retroactively extend them. What this does NOT change: the "Remember Me" checkbox still controls only storage location (cleared on browser close vs persisted across restarts). The relabel from misleading-UX-perspective is left for a separate follow-up — that's a UX choice independent of the session-policy mechanism. API tokens (MAX_TOKEN_LIFETIME_DAYS), camera stream tokens (60min), WebSocket tokens (60min), and slicer download tokens (5min) keep their own TTLs and are unaffected. Tests: 15 new cases inbackend/tests/integration/test_session_policy.pysplit across three classes.TestResolveSessionMaxMinutespins the clamping resolver — missing row, empty string, unparseable value, zero/negative, 1h minimum, 7-day passthrough, 30-day passthrough, above-ceiling clamp.TestLoginRespectsSessionPolicydecodes the JWTexpclaim end-to-end and asserts the token returned by/auth/loginhonours the configured ceiling for the default-24h, configured-7d, and above-ceiling-clamp cases.TestSettingsAPIExposesSessionMaxHoursround-trips the field through/settings/(default = 24, valid update persists as int's string form, zero rejected with 422, above-ceiling rejected with 422). Existing 202-case auth + MFA suite still green. i18n: 8 new keys insettings.sessionPolicy.*namespace; full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Parity check 5149 leaves per locale. ESLint clean;npm run buildclean; ruff clean. - Per-VP "G-code injection" toggle for Studio Send / FTP uploads (#1516, contributed by @phieb) — Queue-mode Virtual Printers gain a per-VP opt-in toggle that applies the Settings → G-code Snippets per-model start/end snippets to every job that lands via the VP — Bambu Studio's "Send", OrcaSlicer's "Print Plate", the VP's own FTP upload path. Before this change the snippets were only applied to items queued through the PrintModal's "Inject auto-print G-code" checkbox; VP-incoming jobs silently bypassed injection regardless of how the snippets were configured. Default off so upgraders don't silently start injecting: existing
gcode_snippetsinstalls keep their previous behaviour until the per-VP toggle is explicitly enabled. When on, the scheduler still no-ops unlessgcode_snippetsare configured for the target printer model, so the effective semantics are "inject when enabled AND snippets exist." DB column: newvirtual_printers.gcode_injection BOOLEAN DEFAULT FALSEwith a branchedis_sqlite()migration (SQLiteDEFAULT 0/ PostgresDEFAULT FALSE) matching thequeue_force_color_match/tailscale_disabledprecedent. Multi-plate stamping: the flag is set on every plate'sPrintQueueIteminside the per-plate loop introduced by #1697 / #1188, so a multi-plate "Send all" upload now gets snippets injected on each plate consistently — the original PR only stamped the first plate; the merge resolution wove the flag into the loop. Live-toggle correctness: the_sync_from_db_lockedchange detector now comparesinstance.gcode_injection != vp.gcode_injection, so toggling the value in the UI triggers a VP restart instead of letting the in-memory instance keep the stale flag and silently propagate it onto every subsequent upload — same shape as the #1552 family. Backed by a dedicatedtest_sync_from_db_restarts_on_gcode_injection_toggle. UI: new toggle onVirtualPrinterCard.tsx(queue mode only — the toggle is hidden in archive/review/proxy modes since the feature is queue-specific), with the standardupdateMutationsave-on-click + toast on success, plus thependingAction='gcodeInjection'opacity dim during the round-trip. PrintModal hardening: when "Inject auto-print G-code" is ticked on a reprint at quantity > 1, the modal now routes ALL copies through the queue (not just copies 2..N) so the scheduler injects every dispatch — see the separate reprint-quantity entry below for the full motivation. A newuseEffectclears the stalegcodeInjectionstate if the user ticks the box at quantity 2, then drops back to quantity 1 — the checkbox hides at that point and the state must follow, otherwise the immediate-reprint path would silently bypass injection. Diagnostics: the resolved start/end snippets (with{placeholder}substitution already applied) are logged at DEBUG so any "snippet didn't run" report can be traced from a log bundle. i18n: newvirtualPrinter.gcodeInjection.title+descriptionkeys translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW); parity check 5188 leaves per locale, no English fallback. Tests: 2 new unit cases intest_virtual_printer.py(queue items opt in / out based on the VP flag), 2 new integration cases intest_virtual_printer_api.py(create defaults to false, PUT round-trips the value), 1 new sync-restart case, plus updates to_make_db_vpso the change-detector test fixture carries an explicitFalserather than relying onMagicMocktruthiness. 2 new PrintModal vitest cases pin the reprint dispatch matrix (injection ON queues all copies and dispatches none immediately; injection OFF keeps the immediate first copy and queues the rest). Full backend pytest 6167/6167; full frontend vitest 2154/2154; ruff clean;npm run buildclean. - AMS Filament Backup status + control on the printer card — New per-printer surface that mirrors BambuStudio's "AMS Filament Backup" checkbox (the per-AMS auto-switch to a second matching spool when one runs out). Until now Bambuddy had no read or write access to the printer-side backup state; the only way to change it was via the slicer or the printer's touchscreen, and Bambuddy's "Prefer lowest remaining filament" preference was ignorant of it (see the linked Fixed entry for #1766 — the two ship together). Backend — parse the state. New tri-state
PrinterState.ams_filament_backup: bool | Nonepopulated from bit 18 of the top-levelprint.cfghex string on every push_status (bambu_mqtt.py::_process_message~line 1037). New module-level helperparse_ams_filament_backup_from_cfg()returnsNoneon absent / non-hex / non-string input so old-protocol families (A1 / A1 Mini, which emit nocfg) preserve today's behaviour — the tri-state default applies the dispatcher's sort, never coerces to OFF, so A1 users see zero regression. Verified against OrcaSlicer source (DeviceManager.cpp:4961SetAutoRefillEnabled(get_flag_bits(cfg, 18))) and a live H2D ON/OFF capture during this work — the cfg flips exactly betweenC0340FC219(bit 18 set, ON) andC0340BC219(bit 18 clear, OFF), only the fifth nibble changing. Backend — toggle. NewPOST /printers/{id}/ams-backup?enabled=<bool>route gated onPermission.PRINTERS_CONTROLcallsclient.set_ams_filament_backup(enabled)which routes through_set_print_option("auto_switch_filament", enabled). The MQTT payload shape{"print": {"command": "print_option", "auto_switch_filament": <bool>, "sequence_id": "20000"}}was verified by capturing BambuStudio's own command on the request topic with a temporary outbound diagnostic logger — single field at a time, never bundled with otherprint_optionflags, so we never clobber other state. Optimistic local state update lives inside_set_print_optionimmediately after_client.publish(...). Hold-timer guard (_xcam_hold_start["print_option_auto_switch_filament"], 3 s window, mirrors the existing xcam pattern for spaghetti / first-layer detector settings): when the user just toggled via Bambuddy's badge, the next 1-2 push_status frames may still carry the printer's PRE-toggle cfg before the firmware reflects the change — without this gate the badge would flicker ON→OFF→ON on every toggle. The hold fires only when Bambuddy itself initiated the change; Studio-side or printer-display toggles propagate immediately. Backend — inventory-remain endpoint. NewGET /printers/{id}/inventory-remainroute exposes the sameMap<global_tray_id, grams>the dispatcher uses (via the existing_build_inventory_remain_overrideshelper), so PrintModal's client-side "Prefer Lowest Remaining Filament" sort can apply the same two-tier ordering the backend would on dispatch. Internal AND Spoolman modes both work uniformly via the existing helper's mode branch — external / VT slots excluded, negative grams clamped tomax(0.0, label - used). JSON-keyed-as-string convention so the wire format is clean; client coerces back to Number on receive. Permission:Permission.PRINTERS_READ(same as reading printer status). REST + WS response surface.printer_state_to_dictand thePrinterStatusResponsePydantic schema both extended with the new field; the printer's REST/printers/{id}response carriesams_filament_backup.state.ams_filament_backupadded to thestatus_keydedup tuple inmain.py:1101so backup toggles trigger an immediate WS broadcast and clients see live state changes whether the toggle came from Bambuddy, BambuStudio, or the printer's touchscreen. Frontend — printer card badge. Small icon button in the "Filaments" section header on each printer card (PrintersPage.tsx), placed beside the section label so the printer-wide nature reads correctly (the cfg bit is one per printer, not per AMS unit — the original draft put it per-AMS row, which would have duplicated the same state on multi-AMS printers and looked confusing). Three states: ON = blue circular-arrow icon (Repeatfrom lucide-react) onbg-blue-500/20; OFF = dim icon onbg-bambu-dark; unknown (A1 family / no cfg yet) = "?" character on dim background, click disabled. Click on a known state toggles via the new endpoint, with optimistic update and success toast (AMS Filament Backup enabled/disabled). The mutation invalidates BOTH'printerStatus'(camelCase) and'printer-status'(kebab-case) cache keys — the codebase has both conventions in active use (useFilamentMapping-related hooks use kebab, everything else uses camelCase), so only hitting one would leave PrintModal showing stale backup state if the user toggled from the printer card while the modal was open. i18n. 5 new keys in theprinters.amsBackup.*namespace (titleOn,titleOff,titleUnknown,toastEnabled,toastDisabled) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Tests. 12 new backend cases —test_bambu_mqtt_cfg_parse.py(parser × 13: real H2D ON/OFF captures, X1C short hex, lowercase, isolated bit-18 set / clear, every malformed shape returns None safely — note: 1 case is a parametrized invalid-input set of 7 sub-cases so the test file shows 13 reported cases) andtest_bambu_mqtt.py::TestAmsFilamentBackupHoldTimer(× 3: stale push during hold ignored, push after hold applies, same-value push during hold no-op). Two PrinterState SimpleNamespace stubs intest_printer_offline_notification.pyandtest_printer_manager_status_broadcast.pyextended withams_filament_backup=Noneto match the newstatus_keyfield; full pytest confirms no other stub needed updating. What this does NOT do. Cover A1 / A1 Mini: those models emit nocfgfield in push_status so the badge shows?and the dispatcher's sort applies as before. Once we identify the A1-specific field (waiting on a future Discord owner with a clean ON/OFF capture) we'll populate it via a model-specific path; until then the tri-state default keeps zero regression. Affect downstream consumers of PrinterState:mqtt_relay, webhook routes, and Home Assistant integration enumerate fields explicitly, so addingams_filament_backupdoesn't change what they emit. Full backend pytest 6217/6217; full frontend vitest 2170/2170; ruff clean;npm run buildclean; ESLint clean. - Sort File Manager folder tree by recent activity (#1770, requested by @Kingbuzz0) — Until now the folder tree was always sorted alphabetically by name, both backend (
order_by(LibraryFolder.name)) and frontend. The reporter — a user with a lot of nested cad / slicer directories — wanted "find folders that just got a new 3MF" without scrolling the whole alphabet. What changed. The folder sidebar header gains a small dropdown (By name / By recent activity) plus an asc / desc arrow button, sitting alongside the existing Collapse + Wrap toggles. Choice persists per-browser vialocalStorage(library-folder-sort-field,library-folder-sort-direction) so the preference survives reloads. Activity semantics.latest_activity_atper folder =MAX(folder.updated_at, MAX(immediate-child file.updated_at)). The DB had the data —LibraryFile.updated_atisonupdate=func.now()andLibraryFolder.updated_atthe same — butLibraryFolder.updated_atalone only bumps on rename / move, not on file-add inside the folder, which is exactly the wrong signal for "did I just drop a new model in here." The aggregate fixes that. Recursion across subfolders is intentionally NOT computed — a deeply nested new 3MF bubbles its immediate parent, not every ancestor up to the root. This keeps the route a singleGROUP BYrather than a recursive CTE, matching the existing file_counts subquery shape sibling atlibrary.py:746. A future Tier 3 follow-up could add the recursive-CTE variant if anyone reports deeply-nested updates not bubbling far enough. Backend. Newlatest_activity_at: datetime | Nonefield onFolderResponseandFolderTreeItemschemas. The/folderstree route picks up a siblingfunc.max(LibraryFile.updated_at)group-by alongside the existing file-count subquery; resolves the field per row. The/folders/by-project/{id}and/folders/by-archive/{id}routes collapse their per-row file-count subquery to fetchcount + maxin one trip (one extra column, zero extra round-trips). All 5 single-folder constructors (POST/folders, GET/folders/{id}, PUT/folders/{id}, POST/folders/external, the create flows) populate the field withmax(folder.updated_at, latest_file)or fall back tofolder.updated_atwhen there are no files, so the API surface is consistent across every route that returns a folder. External folders.LibraryFilerows are created for scanned external files too (library.py:526), so the MAX aggregate works on them — but the timestamp reflects when Bambuddy last scanned / re-indexed the file, not the filesystem mtime. For a NAS that gets new files added outside Bambuddy, the activity-sort lags until the next scan. Documented in the file-manager wiki page rather than papered over withos.stat()on every list call, which would stall the route on slow mounts. Frontend. A new recursivesortedFoldersuseMemoapplies the comparator uniformly to top-level + every nestedchildrenlevel so sort order is consistent at every depth. Comparator falls back to name when activity timestamps tie or are both null, so an empty folder never elbows a recently-used one to a random place — empties go to the end of the activity bucket regardless of direction. Both the desktop sidebar render and the mobile selector dropdown consumesortedFoldersso the order is identical across breakpoints. The single-folderfindFolder()traversal andselectedFoldermemo still operate on the unsortedfoldersbecause they index by ID — sort-order-independent. Recursion safety. The sort creates fresh object refs at every level on every memo invocation; theFolderTreeItemkeys stay ID-based (${folder.id}-${collapseFoldersByDefault ? 'c' : 'e'}) so React reconciliation by ID preserves folder expansion state across sort flips. i18n. 3 new keys infileManager.*(folderSort,folderSortByName,folderSortByActivity) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Parity 5238 leaves per locale. Tests. 2 new backend integration cases intest_library_api.py(file-in-folder bubbleslatest_activity_atto the file's timestamp, empty folder falls back tofolder.updated_at). All 152 library + folder + trash + slice integration tests still pass; 51/51 FileManagerPage frontend tests still pass; 26/26 QueuePage tests still pass;npm run buildclean;ruffclean; i18n parity green.
Fixed
- Skip Objects listed the wrong plate's objects (#2522, reporter @bordermultimedia) — The reporter uploads all-plates sliced files (
*.gcode.3mf) and picks the plate at dispatch, which works. But Skip Objects then offered him four objects while the plate he was printing had one — and the four IDs it showed were, verbatim, the four copies sitting on a different plate of the same file. His 3MF confirms it exactly: plate 1 holds fourstand_pillow_01.stlinstances (identify_ids 2040 / 2062 / 2084 / 2106), plate 2 holds one (2168). He printed plate 2 and got plate 1's list. Root cause, two defects in one helper.extract_printable_objects_from_3mf()has taken aplate_numberargument all along, and not one of its three call sites ever passed it — print start from an archived file, print start from the FTP-downloaded file, and the modal'sreloadall called it bare, so it fell toroot.find(".//plate"), the first plate in the file. And passing one would not have helped: the lookup was.//plate[@plate_idx='N'], a predicate on an attribute neither Bambu Studio nor OrcaSlicer writes — the plate index lives in a<metadata key="index" value="N"/>child, which is how the rest of the codebase (threemf_tools.py,filament_requirements.py) already reads it. The XPath simply never matched and fell back to plate 1 regardless. The thumbnail behind the markers was right the whole time, because/coverresolves the plate properly viaresolve_plate_id()— which is why the reporter saw plate 1's four markers arranged in a square over plate 2's single pillow. Fix. The plate is now selected on itsindexmetadata, and all three call sites passresolve_plate_id(client.state)— the same resolver/coveruses, so the object list and the thumbnail it is drawn over cannot disagree again. It covers both cases: Bambuddy-dispatched prints (where the plate is known from the dispatch, and must be — the reporter's P1S firmware echoes agcode_filewith no plate path, #1166) and prints started from the printer or Studio (where it is parsed from that path). When no plate can be resolved, or the requested plate isn't in the file, the first plate is still used — the single-plate export, i.e. the common case, is unaffected. Also fixed, same file shape.peek_plate_index_in_3mf()read the first plate too. It backs the #1204 guard, which compares the plate inside a freshly-downloaded 3MF against the plate the printer reports and discards the file on mismatch. Fed an all-plates upload it always answered "plate 1", so anyone printing plate 2+ of such a file from the printer screen had their perfectly good 3MF thrown away and got a no-3MF fallback archive — no metadata, no skip objects at all. It now returnsNonewhen the file carries more than one plate, because "which plate is this file" has no answer for an all-plates export; single-plate exports still report their index and #1204 behaves as before. Tests. 10 cases over a fixture mirroring the reporter's file: per-plate object lists and marker positions resolve independently, an unknown plate falls back without mixing one plate's objects with another's positions, a single-plate export keeps its own index, the multi-plate file yields no peek index, and the print-start wiring passes the dispatched plate and the gcode-parsed plate. Verified by mutation — dropping the plate argument reproduces the reporter's exact symptom, "Loaded 4 printable objects" for a one-object plate. Scope. Backend only. No DB migration, no schema change, no new permission, no i18n change. - Large prints uploaded twice at once and never landed (#2529, reporter @PDXDave23) — On a 14-printer A1 farm, dispatches "failed over and over" and looked like flaky WiFi. The reporter's screen recording is the proof: the dispatch toast tracks a single job (96.1 MB, one printer), yet its byte counter alternates between two independently rising series — 69.4 → 70.8 MB and 1.6 → 2.9 MB, both climbing at ~75 KB/s. That is not a progress bar jumping. That is two FTP transfers of the same file, to the same printer, at the same time, reporting into the same bar. Root cause.
upload_file_asynccarried a flattimeout: float = 600.0and ran the transfer asasyncio.wait_for(loop.run_in_executor(...)).wait_forcancels the future; it cannot cancel a thread already running in an executor. So on a link slow enough that a big file needs more than ten minutes — 96 MB at 75 KB/s needs twenty — the await gave up at 600 s, returnedFalse, andwith_ftp_retrystarted attempt two while attempt one was still streaming, both writing the sameremote_path. Do the arithmetic and the stale series sits right where the ten-minute clock expires. Withftp_retry_count: 3plus the A1's prot_p→prot_c fallback that is up to six concurrent STORs onto one SD card; each orphaned thread also permanently held a slot in the default executor pool, which on a farm can starve every other FTP operation. The flat cap was never a failure detector in the first place — a link that has actually died is caught withinsocket_timeout(30 s) by the blockingsendall. It only ever punished large files. Fix, three parts. The deadline is now derived from the file size against a deliberately pessimistic 25 KB/s floor (_upload_deadline), so a slow-but-healthy transfer is allowed to finish. A deadline expiry now genuinely stops the transfer: it signals the worker, which raisesUploadCancelledfrom its progress callback — the existing cancel path inupload_file, which breaks the send loop and deletes the partial file from the printer — and the caller waits for the thread to actually go before returning. Andwith_ftp_retrynever retries anUploadCancelled: the deadline means the link sustained less than the floor rate for the whole transfer, so a retry would only spend another full deadline learning that again, and withcheck_queueserialized four of those would block the entire print queue for hours. A per-printer upload lock closes the last hole, so no two uploads can ever overlap on one printer regardless of how they were triggered. Queue items that hit the deadline now say the upload was too slow and to check the printer's WiFi, rather than the old (and here entirely misleading) "check if SD card is inserted". Tests. 6 cases: the deadline scales with file size and floors correctly; a timeout stops the worker thread and cleans its partial file off the printer; a timeout is not retried; concurrent dispatches to one printer serialize; and the real client's cancel path removes the partial file, driven end to end against the mock FTPS server. Verified by mutation — dropping the cancel signal, the retry guard, or the lock each fails its test. Scope. Backend only. No DB migration, no schema change, no new permission, no i18n change. - P1 AMS drying can only be started at the printer — Bambuddy no longer offers it (#2533, reporter @naldo29) — The reporter found the answer to why his P1S accepted every drying command and never dried, and it is in Bambu's own P1 manual: "P1S connected AMS drying functions may only be controlled from the P1S screen." The P1 firmware acks
ams_filament_dryingwithresult: successand then discards it. Nothing we send can start a cycle, on any firmware version — so the honest thing is not to offer.supports_drying()now excludes the P1 series outright, in place of the01.08+firmware gate it has carried since the feature shipped (#292); that version was when P1 firmware gained AMS 2 Pro support, and it was never verified against a live P1 that the printer would take a command. The/drying/startand/drying/stoproutes refuse with a specific 400 rather than publishing a message the printer will drop, and queue / ambient auto-drying skip P1 printers for free, since they gate on the same helper. The control stays visible. A newdrying_screen_onlycapability flag keeps the flame button on the card, disabled, with a tooltip saying drying on this printer is screen-only — a P1 owner should learn where to dry, not watch the feature quietly disappear. A cycle started at the printer still shows in Bambuddy with its live countdown, because reading state was never the problem; only the Stop control goes away, since a P1 ignores stop exactly as it ignores start. Docs. The wiki's firmware matrix said P1P/P1S were supported from01.08.00.00, and (separately, and also wrongly) that P2S / H2S / H2C were not supported at all — the whole table has been rewritten against the code. Tests. 8 new cases: the model gate (both P1 variants, case- and whitespace-insensitive; screen-only ≠ unsupported, since an A1 has no drying-capable AMS at all while a P1 does), the two routes refusing without publishing anything to MQTT, a commandable model still dispatching, and two component cases pinning the disabled-with-explanation button and the stop-less countdown. Scope. One new response field. No DB migration, no schema change, no new permission; 1 new i18n key across all 11 locales. - "Start Drying" gave no feedback and reported success the printer never delivered (#2533, reporter @naldo29) — On the reporter's P1S the button appeared dead: no toast, no badge, no drying. Their bundle shows Bambuddy doing everything right — the
ams_filament_dryingpayload matches BambuStudio field-for-field, was sent three times with the printer idle, and the firmware answeredresult: successto each — while the AMS 2 Pro (module_type: n3f) stayed atdry_status: 0. The printer takes the command and drops it. Two gaps on our side made that indistinguishable from a broken button. No confirmation.startDryingMutation/stopDryingMutationclosed the popover and invalidated the status cache without ever toasting, alone among the printer card's actions — so a user who clicked Start had nothing at all to tell them the click had registered. Both now toast on success — and the start toast says "Drying command sent", not "Drying started", because at that moment the printer has only taken the command. What says the cycle is genuinely live is the amber countdown badge, which is keyed ondry_timeand therefore only appears when firmware reports one. Success was inferred from the MQTT ack. The #971 guard that turns firmware's refusal into a real message ("Plug in the external AMS power adapter", "AMS is already drying", …) reads the per-unitdry_sf_reasonarray — and P1-family firmware never publishes that field, so on a P1S the guard is inert and the ack is all we have. The card now watches the unit after the ack:dry_statusanddry_timecome straight from theinfobitmask on every push, and firmware moves toDryStatus 1(Checking) within seconds of a real start. If the unit is still at zero 30 seconds later the cycle never began, and Bambuddy says so and names the two things that cause it (AMS power adapter not connected; printer not idle — P1S cannot dry mid-print, seesupports_drying_while_printing). Unlike thedry_sf_reasonguard this is model-agnostic: it catches any firmware that acks and declines. Tests. 4 component cases — the send toast, the stop toast, the warning firing when the AMS never leavesdry_status: 0, and no warning when it does enter a cycle. Scope. Frontend only; 3 new i18n keys across all 11 locales. No DB migration, no schema change, no new permission. - Enabling authentication silently disconnected Bambu Cloud (#2530, reporter @hburn7) — Cloud credentials live in two different places depending on auth state:
get_stored_token()reads the globalSettingsrows (bambu_cloud_token/_email/_region) when auth is off, andUser.cloud_tokenwhen it's on. CompletingPOST /auth/setupflipped which store the/cloud/*routes consult, but nothing carried the token across — so an operator who linked their Bambu account before turning on auth found the freshly-created admin hadcloud_token = NULL.build_authenticated_cloud()then returnedNoneand every cloud route degraded:get_filament_infoskipped its cloud phase entirely and answered200from the local-preset and built-in-name fallbacks, while/cloud/devicesbegan returning401. Nothing surfaced the disconnect. The reporter observed the symptom inverted — cloud400warnings vanished after enabling auth — and reasonably read that as a fix; in fact the warnings stopped because Bambuddy had stopped calling the cloud at all. The tell is in their own timestamps: the pre-auth request spent ~960 ms on cloud round-trips, the post-auth one answered immediately. Fix.setup_auth()now migrates a globally-stored token onto the owning admin (and deletes the global rows, so a live credential isn't left at rest in a table nothing reads), anddisable_auth()performs the mirror hand-off back to global storage. Both refuse to guess when ownership is ambiguous: setup migrates only when it creates the admin or exactly one admin already exists — with several admins it leaves the credential in place and logs a warning rather than handing one admin another's Bambu session; disable declines to overwrite a pre-existing global token. Theregionsurvives both hops rather than silently resetting toglobal. Note for existing installs. The migration runs at the auth on/off transition, so instances that already crossed it must re-link their Bambu account once from Settings → Bambu Cloud; the strandedbambu_cloud_*rows insettingscan then be deleted. Tests. 7 integration cases pinning both directions, the two refuse-to-guess paths, theauth_enabled=falseno-op, and region preservation. Scope. No DB migration, no schema change, no new permission, no i18n change. - Routine cloud preset misses no longer log at WARNING (#2530) — The
Failed to get cloud preset … 400 {"message":"missing"}lines that led to #2530 being filed are an expected answer, not a fault, andget_filament_info's Phase 3 already resolves the name from local presets (a bareGFL05lands on "Overture Matte PLA" without the cloud). Two routine causes, both confirmed against the live Bambu catalog: many official presets are only addressable with a printer-variant suffix —GFSA00andGFSL99resolve bare, butGFSL05andGFSG00exist only asGFSL05_07(@BBL A1),GFSG00_06and so on, while the AMS reports the bare ID; and personal presets (P…, e.g.Pb5b7d17) belong to whichever Bambu account sliced the file, so no other account will ever resolve them. Emitting a WARNING per tray on every AMS tooltip refresh trains operators to ignore the log.BambuCloudErrornow carries the upstreamstatus_code, and the preset lookup logs an HTTP 400 at DEBUG while leaving every other failure — expired token, 5xx, connection error — at WARNING, so a genuine fault is still loud. Deliberately not fixed here: resolving the variant suffix. The suffix selects a printer profile, and the endpoint returnspressure_advance(the K value), which is per-printer — picking a suffix arbitrarily would populate AMS tooltips with another printer's K value, which is worse than the current blank. Doing that correctly requires threading the tray's printer model intoget_filament_info, which changes the endpoint contract. Tests. 4 parametrised cases drive the real route with a stubbed cloud and assert the 400 lands at DEBUG while 401 / 502 / transport failures stay at WARNING; verified by mutation (forcing the classification off fails the 400 case). - Printer FTPS and MQTT connections inherited their TLS floor from the OpenSSL build instead of declaring one —
ImplicitFTP_TLS(bambu_ftp.py) and the MQTT client (bambu_mqtt.py) both built their context withssl.create_default_context(), which leavesminimum_versionatMINIMUM_SUPPORTED. What that resolves to is a property of the interpreter's OpenSSL build, not of Bambuddy: measured on identicalOpenSSL 3.5.6, thepython:3.13-slim-trixieDocker base reportsTLSVersion.TLSv1_2while a bare-metal venv reportsMINIMUM_SUPPORTED— so Docker users have always been floored at TLS 1.2, while bare-metal and appliance installs could in principle negotiate TLS 1.0 or 1.1 with a printer that offered them. Fix. Both contexts now setminimum_version = ssl.TLSVersion.TLSv1_2explicitly. On the two FTP profiles that also capmaximum_version(P2S, X2D — see #1401) this yields an exact TLS 1.2 pin rather than a ceiling over an inherited floor. Verified against hardware, not just tests. Probing an X1C and an H2D on both:990and:8883, each printer completes only on TLS 1.2 and rejects 1.0, 1.1 and 1.3 with ahandshake_failurealert; a live FTPS login through the changed code path succeeds on both withTLSv1.2negotiated. Since the shipped Docker image already enforced this floor across the whole install base, no printer or firmware reachable today can be affected by making it explicit. Also corrected a stale comment inftp_profiles.pyclaiming X1C / H2D installs "stay on the negotiated TLS 1.3" — both models refuse 1.3 outright, socap_tls_v1_2is a no-op there; the P2S evidently does offer 1.3, which is why it alone surfaced the vsFTPd session-reuse bug. Scope. Two lines plus a comment. No behaviour change on Docker, no DB migration, no new permission, no i18n change; certificate verification is unchanged (printers use self-signed certs, socheck_hostname/CERT_NONEremain by necessity). - Backend failed to start on fastapi < 0.116:
AssertionError: Status code 204 must not have a response body—uvicorn backend.app.main:appaborted at import time while registeringDELETE /api/v1/library/tags/{tag_id}. The route is declaredstatus_code=204with a-> Nonereturn annotation, andlibrary_tags.pyusesfrom __future__ import annotations— so the annotation reaches FastAPI as the string"None", whichget_typed_annotation()resolves viaevaluate_forwardref()toNoneType.NoneTypeis a class and therefore truthy, soAPIRoute.__init__took theif self.response_model:branch and asserted that a 204 may carry no response body. fastapi 0.116 added anif annotation is type(None): return Noneguard that makes this benign, which is why CI and the Docker image (both resolve the top of the>=0.109.0,<0.136.0range) never saw it — only installs pinned to an older release inside that supported range, such as a venv created before the tag catalog landed in #1268, hit the crash. Fix. The route declaresresponse_model=Noneexplicitly, which short-circuits the annotation inference on every fastapi version in the supported range. The sibling 204 route (DELETE /slicer/pipelines/{pipeline_id}) is unaffected — its module has nofrom __future__ import annotationsand no return annotation. Scope. Backend-only, one decorator. No behaviour change on fastapi >= 0.116, no DB migration, no new permission, no i18n change. Existing installs can equivalently unblock themselves withpip install -U -r requirements.txt. - Dependency floors permitted resolutions the code can't run on:
sqlalchemy>=2.0.38, exact ruff pin — Two more instances of the same class of defect as the 204 crash above:requirements.txtdeclared floors low enough that a legitimatepip install -r requirements.txtcould produce an environment Bambuddy fails to start or lint in. CI never caught either, because a fresh runner always resolves to the top of every range — only a longer-lived venv resolving lower hits them. sqlalchemy.core/database._create_engine()passespool_size/max_overflowon the SQLite branch. SQLAlchemy 2.0.38 changed the aiosqlite dialect's default pool for file databases fromNullPool(which rejects both kwargs) toAsyncAdaptedQueuePool(which accepts them); on 2.0.0-2.0.37 the module-levelengine = _create_engine()raisesTypeError: Invalid argument(s) 'pool_size','max_overflow' sent to create_engine()at import, taking down every SQLite install and the whole test suite (conftest.pyimports the module). Postgres installs were never affected —is_sqlite()is False and the branch is dead. Floor raised tosqlalchemy>=2.0.38. ruff. The lint job ran a barepip install ruff(always the newest release) whilerequirements-dev.txtsaidruff>=0.8.0, so CI's linter and a contributor's were routinely different programs enforcing different rule sets. A venv holding ruff 0.8.4 reported 32 errors against a tree current ruff calls clean — 30 of themUP038, a rule ruff has since removed (PEP 604 syntax inisinstance()is slower than the tuple form it wanted you to replace). ruff is now pinned exactly (ruff==0.15.20) and the CI lint job installs that pin fromrequirements-dev.txt, so local and CI enforce the same rules andformat --checkcan't disagree across machines. Scope. Packaging + CI only; no application code, no DB migration, no permission, no i18n change. Existing environments should re-runpip install -U -r requirements.txt -r requirements-dev.txt. - AMS slot with a non-Bambu (no-RFID) spool showed "Empty" instead of "?" (#2527, reporter @NeighborGeek) — When a spool without a readable RFID tag was loaded, the AMS card showed the slot as Empty, while Bambu Studio correctly showed a
?for an unidentified filament. The reporter's decisive test — swapping the unknown spool between slots and watching "Empty" follow the spool, not the slot — pinned it to slot content, not position. Root cause: the authoritative "a spool is physically here" signal is firmware's AMS-leveltray_exist_bitsbitmask (what Studio uses to draw the?), but Bambuddy inferred emptiness from the per-traystate/tray_type. On the standard AMS (P1-series here, fw 01.09.00.00), a no-RFID spool is reported with an emptytray_typeandstate=9— structurally identical to a truly-empty slot at the tray level — so the frontend'sgetEmptySlotKind()classified it as firmware-confirmed-empty and rendered "Empty" rather than the existingresetkind that renders?("Spool loaded — slot not configured", #1694). Confirmed from the support bundle:tray_exist_bits='f'(all four slots present) withtray_is_bbl_bits='5'(only slots 0,2 are Bambu) — slots 1,3 were present-but-non-Bambu, exactly the ones shown Empty. Fix.apply_tray_exist_bits()— which already parses the bitmask to clear stale fields on absent slots — now also annotates each slot with an authoritativeexistsbool (gated behind a newannotate_existsflag so only the printer-card path sets it; the VP bridge leaves it off and theexistskey never reaches the slicer wire format).existsflows through theAMSTrayschema/serialization to the frontend, wheregetEmptySlotKind()uses it:exists === true+ notray_type→?(present, unconfigured),exists === false→ Empty, andexistsabsent → the previousstate=9/10heuristic (so AMS-HT and missing-bitmask paths are unchanged). This is why the bug never reproduced on H2D or X1C — their firmware already reports present-unknown slots with a non-9state, so they fell through toreset/?; with the fix they take the same path viaexistsand are unaffected. Supersedes the closed #1838. Tests. Backend: 3 helper cases (present/absent slots annotated, a present-no-tray_typeslot markedexists=trueand left uncleared, andannotate_existsoff keeps the wire dict clean). Frontend: 1AmsUnitCardcase (astate=9slot withexists=truerenders?, whileexists=falsestill renders "Empty"). Fulltest_bambu_mqtt+ VP-bridge suites 384/384 and the AMS/printer/VP backend selection green;ruffclean;npm run build+ ESLint clean;AmsUnitCard/PrintersPagevitest green. Scope. No DB migration, no new permission, no i18n change; VP slicer-facing wire format unchanged. - Postgres→SQLite backup dropped NOT NULL / DEFAULT / FK / UNIQUE, causing NULLs after restore (#2526, reporter @bmorrison9) — On a PostgreSQL install,
create_backup_zip()exports a portable SQLite copy of the database so backups can move between engines. It rebuilt each table with only column name + type + primary key — the code's own comment admitted "simplified — just column names and types" — droppingNOT NULL,server_default/DEFAULT, foreign keys, and unique constraints. When such a backup is restored onto a SQLite install,restore_backup()page-copies the file straight onto the live database (sqlite3.Connection.backup()), so the stripped-down schema becomes the running database; the post-restoreinit_db()can't repair it becausecreate_all()isCREATE TABLE IF NOT EXISTSand never alters existing tables. The reporter root-caused it precisely:SpoolBuddyDevice.created_atisserver_default=func.now(), so SQLAlchemy omits the column on INSERT and relies on the DB default — but with noDEFAULTclause the row got a bareNULL, which then failed Pydantic validation (DeviceResponse.created_at) on the next read and 500'd. Everyserver_defaultcolumn across the schema was exposed the same way, and the FK/unique loss followed from the same simplified CREATE TABLE. Fix. The PostgreSQL branch now builds the portable SQLite schema withBase.metadata.create_all()against a SQLite engine — the exact DDL a native SQLite install gets — instead of the hand-rolled loop. That emitsNOT NULL,DEFAULT(server_default=func.now()→DEFAULT (CURRENT_TIMESTAMP)), foreign keys, unique constraints, and indexes, so a Postgres→SQLite restore reproduces the same effective schema a fresh SQLite install would have. The data-export insert path is unchanged, and the#1333OIDC-icon guard is preserved automatically —LargeBinaryrenders asBLOBunder the real DDL — which let the now-redundant_sqlalchemy_type_to_sqlite_type()type-mapping helper be removed. This fixes newly-created backups; a backup taken with an older build still carries the degraded schema, so re-take backups after upgrading. Tests. The#1333type-mapping unit tests were replaced with three that inspect the real backup schema (metadata.create_allon SQLite, read back viasqlite_master/PRAGMA table_info): the OIDCicon_datacolumn isBLOB(#1333),spoolbuddy_devices.created_atkeeps itsCURRENT_TIMESTAMPDEFAULT (#2526), and a NOT NULL non-PK column stays NOT NULL. Full backup/restore suite (test_settings_api,test_security, Postgres-restore-cascade, SQLite-WAL-safety, OIDC-blob-roundtrip) 136/136 green;ruffclean. Scope. Backend-only, PostgreSQL-source backups. No DB migration, no new permission, no i18n change. - "Store on external storage" diagnostic reported an unresolvable fail on P1S/P1P (#2524, reporter @gregspatrick) — Install-step-4's
external_storagecheck hard-failed for a P1S even though there is no reachable UI anywhere — Bambu Studio, OrcaSlicer, Handy, or the printer (P1S has no screen) — that can turn the option on. The reporter root-caused it precisely:has_external_storage()returns True for the P1S (it does have a MicroSD slot), so the check proceeds to readstate.store_to_sdcard(MQTThome_flagbit 11), which is stuckFalse. The toggle only renders in Studio when the printer publishessupport_save_remote_print_file_to_storage, and current P1-series firmware (through 01.10.00.00) never does — Bambu's own storage-cache wiki lists P1 Series as "Not Supported". So the user was shown a red fail they could never clear. Fix. NewNO_REMOTE_STORAGE_TOGGLE_MODELSset (P1S, P1P) +has_remote_storage_toggle()helper, distinct from the no-slotNO_EXTERNAL_STORAGE_MODELSused for A1/A1 Mini (the P1S genuinely has a slot — conflating the two would be wrong). When a model has a slot but no reachable toggle andstore_to_sdcardis False, the diagnostic now emitsskipwithparams={"reason": "unsupported_model"}instead offail, and the overall result no longer escalates to "problems" for it. A P1S that somehow reports the option on still passes. The gate is model-scoped and default-open, so X1/P2S/H2-class printers — where the toggle is reachable and the fail is actionable — are unaffected; if a future P1 firmware surfaces the capability, drop the model from the set and the check reactivates. The frontendDiagnosticChecklistpicks a reason-specific message variant (external_storage.skip_unsupported_model) when a check carries areason, falling back to the plain per-status text otherwise — so instead of the misleading generic "needs a live MQTT connection" skip line, P1 users see an accurate explanation that the option can't be enabled on current firmware and archived prints may lack thumbnails/metadata until Bambu adds support. i18n. 1 new key translated across all 11 locales; parity green at 5580 leaves each. Tests. Backend: 3 diagnostic cases (P1S/P1P → skip with the reason param, overall stays "ok"; P1S with the option on still passes) + 3 helper cases intest_printer_models.py(P1-series false, other models/unknown/empty true). Frontend: 2ConnectionDiagnosticModalcases (reason variant renders and suppresses the generic text; no-reason falls back). Full diagnostic + model suites green;ruffclean;npm run build+ ESLint clean. Scope. No DB migration, no new permission. - Finish photo still caught the swapped/empty plate intermittently on A1 Mini + SwapMod (#1867 follow-on, reporter @qoatzelcoat) — The last-layer edge trigger shipped in 0.2.4.9 fixed most cases but the reporter still saw the wrong (post-swap) plate now and then — "nothing changed, just kept adding files to the queue." Root cause, confirmed from the support bundle: this A1 Mini firmware (01.08.01.00) never emits
stg_cur=22— across the whole 35k-line log (24 completions) the only stages it reports are 0/2/3/4/13/14/54/77/255, so every completion falls through to theFINISH-state fallback. Bambu only reportsgcode_state=FINISHafter the user End G-code runs (the print's last object layer was laid ~2 min before FINISH), so a live grab there is guaranteed to show the SwapMod-ejected plate. The last-layer edge (layer_num >= total_layer_numwhile RUNNING) is the right window but depends on catching one transient MQTT packet — if the firmware coalesces or drops the finallayer_num == totalpush and jumps straight to FINISH, the edge is missed and it silently reverts to the post-swap grab. That's the intermittency; queued prints run unattended so the misses accumulate. Fix — bank a frame instead of chasing an edge. Bambuddy now keeps a rolling "last in-print camera frame" per printer, refreshed on layer change (throttled to ~25 s, always refreshed on the final object layer) via the same snapshot path the finish photo uses — so it honours thecapture_finish_photosetting and works for external cameras, buffered RTSP, and fresh RTSP grabs alike. Because banking is layer-driven it freezes automatically the instant printing ends: the End G-code (plate swap) emits no furtherlayer_numincreases, so the last banked frame is always the finished print before the swap. On theFINISH-state fallback the finish-photo path now prefers the banked frame over a live grab; thestage_22andlast_layertriggers still live-grab (they fire before the swap and give cleaner parked-toolhead framing), and if no banked frame exists it degrades to the old live grab rather than sending a text-only notification. Correctness no longer depends on which signal the firmware emits or on catching the edge — a missed edge just means the photo is one layer-frame stale (a finished print, not an empty plate). The bank is cleared on print start so a queued job can't reuse the prior job's frame. Tests. 8 new cases intest_finish_photo_moment_sync.py:finish_stateprefers the banked frame and skips the live grab, falls back to live when no bank exists, andlast_layerignores the bank; plus 5 for the banking helper — stores while printing, throttles within the interval, always refreshes on the last layer, skips when not RUNNING (the freeze), and skips during calibration sub-stages. Full finish-photo + MQTT + layer-timelapse suites 355/355 green;ruff checkclean. Scope. Backend-only. No DB migration, no new permission, no i18n change. - P1/A1 camera stayed black on load until a ~20-minute self-heal (#2521, reporter @nnimby848) — On chamber-image printers (P1S/A1, port 6000) the camera view frequently came up black on every load/reload and only recovered ~20 min later. The reporter supplied excellent evidence — backend logs, tcpdump (frames actively flowing on port 6000), and a HAR — and correctly identified the trigger: the viewer mounts twice in quick succession (React StrictMode + a self-inflicted reconnect loop), so a short-lived first viewer attaches and detaches within tens of ms while a second viewer persists. Their proposed mechanism (the connection being "attributed" to the dead viewer's trace ID) was a misread of the architecture —
camera_fanout.pyis a shared fan-out (one upstream socket per printer, keyed{id}-fanout), so only the first subscriber logs "Starting/connected" and every later viewer taps the same pump; the trace ID in the log is justcontextvarscontext, and the HARstatus:0is a mid-stream capture artifact, not a missing response. The real defects were two, both real: (1) Late-subscriber cold-start. Every viewer after the first got a fresh empty queue and had to wait for the next upstream frame; on a slow chamber cam plus the churn the<img>never firedonLoad, so the page's stall-detector reconnected every few seconds, minting yet another short-lived subscriber — a self-sustaining loop. (2) Single-connection socket overlap. Port 6000 allows one connection; the churn tore the upstream down and reopened it, and a replacement broadcaster could open a new socket before the old one finished closing (_grace_then_stopexposedstopped=Truebefore the pump's socket-closefinallycompleted). The printer kept feeding the orphaned socket and starved the live one until its TCP keepalive reaped it — the ~20 min self-heal. Fixes. Backend fan-out: the broadcaster now remembers the last chunk it pumped and primes a late/surviving subscriber with it onsubscribe(), so any viewer after the first renders a frame instantly (firesonLoad, resets the reconnect loop, ends the churn); and a replacement broadcaster's pump now waits for the displaced broadcaster's upstream socket to fully close (wait_until_torn_down(), set only after the pump's cancellation + socket-closefinally) before it dials the printer, so two sockets to a single-connection printer never overlap. Guarding at the pump rather than atget_or_create_broadcasterkeeps it correct when concurrent viewers race to replace the same stopped broadcaster — only the single pump dials — and it's bounded by a 10 s cap so a wedged close degrades to the old behaviour instead of never producing a frame. Frontend (CameraPage): the stall-detector now requires two consecutive stalled/inactive status reads (~10 s) before reconnecting, so a single blip during fan-out startup/handover no longer nukes a stream that's about to deliver frames; the strike counter resets on a rendered frame and on each fresh load. Tests. 6 new fan-out unit cases intest_camera_fanout.py— late subscriber primed with last frame, first subscriber not primed,wait_until_torn_downcompletes after shutdown, the replacement barrier blocks until the prior teardown completes, and the barrier's bounded-timeout fallback. Full camera suite (fan-out +test_camera_api+ stderr-summary) 70/70 green;ruff checkclean; frontendnpm run build+ ESLint clean; existing 13CameraPagecases stay green. Scope. No DB migration, no new permission, no new i18n key. The single-connection socket-overlap fix also benefits any single-camera-slot model (e.g. X2D on firmware that permits one connection). If the black screen ever persists on a specific firmware, a per-frame debug counter orss -tn | grep :6000during the episode would confirm whether the upstream is delivering frames — but priming + teardown discipline address both observed mechanisms. - Scanning an external folder no longer deletes the README.md record (and now indexes pre-existing markdown) (#2520, reporter @zumik3-del) — The Folder Readme panel (#1268) worked for a
README.mduploaded through Bambuddy's Upload button, but clicking Scan External Folder afterwards made the panel vanish. The reporter root-caused it precisely:.mdwas absent from_SCANNABLE_EXTENSIONS(backend/app/api/routes/library.py:1342), so theos.walkpass skipped markdown files (:1619) and never added them tofound_paths— and the end-of-scan cleanup loop deleted any existing externalLibraryFilewhose path wasn't infound_paths(:1724), assuming it had been removed from disk. The md file was untouched on disk; only its DB row was destroyed, after which the readme endpoint (GET /folders/{id}/readme, which matchesfilename LIKE '%.md') 404'd and the panel hid. Two-part fix. (1) Added.mdto_SCANNABLE_EXTENSIONS, so the scan now indexes markdown that already exists on disk — markdown dropped in by external tools or copied in manually (feature-request item 1 in the same issue) is picked up and shown, and an uploaded md file is re-found instead of being treated as deleted..mdclassifies asfile_type="md"and hits none of the 3mf/gcode/image thumbnail gates, so it just creates a plain record. (2) Hardened the cleanup loop to gate deletion on actual disk presence (path_str not in found_paths and not os.path.exists(path_str)) rather than mere absence from the extension-filteredfound_paths. This closes the broader class the reporter flagged: any file the upload path admitted whose extension is outside the scannable set (e.g. a.txtnote) would previously be purged from the DB on the next scan even though it still exists on disk — now such records survive, while genuinely-deleted files (absent from disk) are still cleaned up. Tests. 3 new cases intest_external_folders_api.py::TestExternalFolderScan: a pre-existingREADME.mdon disk is discovered by scan and served by the readme endpoint; an uploadedREADME.mdsurvives a scan (removed == 0) and the panel still resolves it — the exact reported bug; a non-scannable.txtupload survives a scan via the disk-presence guard. Full suite 39/39 green;ruff check backend/clean. Scope. Backend-only. No DB migration, no new permission, no i18n change. Item 2 of the issue (the readme panel layout) is addressed in the separate frontend entry below. - Folder README panel no longer crowds out the file list — now a collapsible right-hand rail (#2520 item 2, reporter @zumik3-del) — The Folder Readme panel (#1268) rendered as a full-width block stacked above the file grid, so on a laptop a moderately long README pushed the actual model files (3MF/STL) below the fold, and — because the file list scrolls in its own container on wide screens — there was no single page scroll to get past it; you had to scroll inside the README separately. Fix. On wide screens (
lg+) the panel now docks as a fixed-width right-hand column (w-80/xl:w-96) beside the file list instead of on top of it, so files stay visible and the README scrolls within its own full-height rail. On narrow screens it stacks above the list (order-first) where the page itself scrolls (the reporter's simpler Option A, which is the right behaviour for phones). The panel is collapsible — a header toggle shrinks it to a thin vertical strip (desktop) / slim bar (mobile) with a one-click reopen — and the collapsed/expanded choice is persisted tolocalStorageso hiding it once keeps it hidden across folder switches and reloads (the reporter's Option B — "open it when you need the description, then hide it to free up space"). Implementation:FolderReadmePanelgains the responsive rail layout + persisted collapse state;FileManagerPagewraps the files column and the panel in aflex-col lg:flex-rowcontent wrapper so the panel is a sibling column of the list rather than a block inside it. i18n. 3 new keys (fileManager.readme.show/.hide/.label) translated across all 11 locales (.labelis the proper-noun filename "README", identical by design); parity check green at 5579 leaves per locale. Tests. 2 new cases inFolderReadmePanel.test.tsx: collapsing hides the markdown body, exposes a reopen control, and persists the choice; a persisted-collapsed preference starts the panel collapsed. Existing 3 panel cases + 51FileManagerPagecases stay green;npm run buildand ESLint clean. Scope. Frontend-only. No backend change, no DB migration, no new permission. - Nozzle sizes other than 0.4mm now fully supported in AMS Slot config + pre-dispatch guard (#1899, reporter @TheUltimateC0der; also hit by @icaisolutionsb2b-hub on X2D) — On an H2S (or any printer) with a 0.6mm nozzle installed, the Configure AMS Slot picker only ever offered 0.4mm filament presets, so trays couldn't be set to the profile that matched the slice, and dispatching the 0.6-sliced job made the printer bail out with the cryptic HMS
_8012"Failed to get AMS mapping table". Two distinct gaps. (1) The slot picker was hardwired to 0.4mm.ConfigureAmsSlotModaltakes anozzleDiameterprop that defaults to'0.4'(ConfigureAmsSlotModal.tsx:287) and drives both the local-preset compatibility filter (it builds"Bambu Lab H2S 0.4 nozzle"and rejects imported 0.6 presets whosecompatible_printerslists "…0.6 nozzle") and the K-profile query. Neither call site —PrintersPage.tsxnor the SpoolBuddy kiosk'sSpoolBuddyAmsPage.tsx— ever passed the prop, so the modal assumed 0.4 regardless of the hardware. The real installed diameter was already in scope on the printer status (status.nozzles[0].nozzle_diameter, the same field that renders the "• 0.6mm" badge on the card). Fix: newresolveSlotNozzleDiameter(status, amsId)helper inutils/amsHelpers.tsreads the installed nozzle for a given AMS — on dual-nozzle printers (H2D) it resolves the specific nozzle feeding that AMS viaams_extruder_map[amsId] → nozzles[idx], on single-nozzle printers it falls back to the primary nozzle, and it returnsundefinedwhen the printer hasn't reported nozzle hardware yet so the modal keeps its 0.4 default. Both call sites now passnozzleDiameter={resolveSlotNozzleDiameter(status, slot.amsId)}, so the picker filters presets by the nozzle actually on the machine. (2) No pre-dispatch validation of nozzle size. Nothing in the dispatch path (_compute_ams_mapping_for_printer/_match_filaments_to_slots) ever compared the sliced nozzle diameter against the installed nozzle — the AMS mapping matches ontray_info_idx→ colour → type with a hard filter only on extruder id, never diameter — so Bambuddy would ship a mapping the firmware then rejects with_8012(or0500_4038), leaving the user staring at a printer-side error with no explanation. Fix: a nozzle-mismatch guard in_start_print(backend), placed before preheat and upload so no time is wasted, comparesarchive.nozzle_diameter(parsed from the sliced 3MF'sslice_info;Nonewhen the slice doesn't declare it) against the printer's reported nozzles via two pure helpers_installed_nozzle_diameters()and_nozzle_mismatch_message(). On a positive mismatch it fails the queue item with an actionable message — "File sliced for a 0.6mm nozzle, but the printer has 0.4mm installed. Re-slice for the installed nozzle, or install the matching nozzle before printing." — and fires the same failed-notification + WS event as other dispatch failures. Fail-safe by construction: it blocks ONLY on a positive mismatch — when the slice carries no nozzle diameter, or the printer hasn't reported its nozzles, the guard is a no-op and dispatch proceeds exactly as before; on dual-nozzle printers a match against EITHER installed nozzle passes (a 0.6 slice is fine if one of the two hotends is a 0.6). The 0.05mm tolerance absorbs float noise while staying well inside the 0.2mm gap between adjacent nozzle sizes. Tests. Frontend: 7 cases inresolveSlotNozzleDiameter.test.ts(null/empty status, single-nozzle, dual-nozzle per-AMS resolution, fallbacks). Backend: 15 cases intest_scheduler_nozzle_mismatch.py— 5 for_installed_nozzle_diameters(parse, empty-default stub, unparseable/zero, dual-nozzle), 8 for_nozzle_mismatch_message(block/pass, float tolerance, dual-nozzle either-match, both fail-safe None paths, adjacent-size discrimination), and 2 end-to-end_start_printcases proving a mismatch fails the item before upload/start_print and a match lets dispatch proceed. Existing scheduler suites (cleanup-library, ams-mapping, cancel-race, preheat — 118 tests) stay green, which also proves the guard is a transparent no-op on the existing archive-without-nozzle path.npm run build, ESLint,ruff check backend/all clean. Scope. No DB migration, no new permission, no new i18n key (the failure message rides the existingerror_messagesurface already rendered on failed queue items). Frontend picker change + backend guard only. - "Remember Me" appeared broken — an authenticated visit to
/loginrendered the login form instead of redirecting (#1889, reporter @superdong69) — Users with a perfectly valid, persisted session reported that Bambuddy "never stays logged in": they log in with Remember Me, come back later, and are met with the login form again. The reporter did the legwork and traced it to routing, not session persistence:frontend/src/pages/LoginPage.tsxdestructured onlyconst { login, loginWithToken } = useAuth()and never looked at the authenticated state, so the/loginroute (rendered unwrapped inApp.tsx—ProtectedRouteonly guards the other direction, unauthenticated →/login) showed the credentials step even when the token was live. On that same page load the app's own bootstrap sendsGET /api/v1/auth/mewith the Bearer token and gets a 200 with the full user object — the session is fully alive; only the view is wrong. Why it's easy to hit and self-reinforcing. After a few visits the browser address bar autocompletes the origin to its most-visited path, which becomes/login, so every subsequent visit lands on the form and the illusion of "logged out" compounds. Navigating to/instead lands on the dashboard, logged in, no credentials asked — which is also why it can't be reproduced by testing/directly. Fix.LoginPagenow also readsuserandloadingfrom the auth context and, in auseEffect, redirects an already-authenticated visitor withnavigate('/', { replace: true })once the auth check has settled. The effect is gated onstep === 'credentials'so it never interrupts the 2FA step or the OIDC-callback branch, both of which perform their ownnavigate()afterloginWithToken. It redirects to/rather thanresolvePostLoginRedirect()so it can't consume the OIDC redirect stash — an already-authenticated direct visit has no pending redirect to honour. Tests. 2 new cases inLoginPage.test.tsx(authenticated redirect (#1889)): a live session (token set +/auth/me→ 200) redirects to/withreplace: true; an unauthenticated visit renders the Sign in form and does not redirect. Existing 29 LoginPage cases stay green;npm run buildand ESLint clean. Scope. Frontend-only, routing layer. No backend change, no DB migration, no new permission, no new i18n key. Note this is the routing facet of #1889; the separate token-discard-on-transient-failure hardening inAuthContext(don't drop a valid persisted token on a non-401 blip) is already in the tree. - Multi-nozzle prints no longer collapse all filaments onto one nozzle (#1825, reporter @needo37) — The single-active-extruder shortcut added in #851 (for #827) at
threemf_tools.py:354runsbeforethe per-filamentgroup_idmapping, and fires wheneverextruder_nozzle_statsreports exactly one extruder as having a nozzle installed. On the H2D / H2D Pro / X2D (2-nozzle) and H2C (3+-nozzle tool-changer), this field is data-driven from the slicer profile's enumerated nozzle volume types — when an HT-AMS or High-Flow nozzle's type isn't enumerated in the slice's profile (common with asymmetric extruder setups, e.g. HT-AMS feeding the right nozzle on an H2D), the slicer emits e.g.['Standard#1', 'Standard#0']even though the print genuinely uses both extruders.sum(active_extruders) == 1triggered → every filament was force-assigned tophysical_extruder_map[active_idx], the authoritative per-filamentgroup_idwas discarded, and the Filament Mapping panel showed both filaments badged L with the auto-match hard filter (print_scheduler.py_compute_ams_mapping_for_printer~line 1239) blocking the wrong-nozzle tray as "Type not found". Bug is parser-side and model-agnostic — triggers purely on 3MF data shape, not on the attached AMS hardware: regular dual-AMS H2D installs typically slice to['Standard#1', 'Standard#1'](sum==2) and never enter the buggy branch, which is why this bug was invisible on the most common dual-AMS setup. Physical nozzle routing was not affected — the actual extrude path comes from the sliced gcode + the verbatimnozzle_mappingfrom the project_file (#1780), not from this parse — so the bug surfaced as auto-match failure + wrong L/R badge, not wrong-nozzle extrusion. Fix. Gate the single-active shortcut onlen(distinct_group_ids) <= 1fromslice_info.config. The slice_info parse is hoisted above the shortcut check (and reused by Priority 1) so the gate adds zero extra I/O. When the slice contains ≥2 distinct group_ids, the shortcut skips and the existinggroup_id-based Priority 1 mapping runs. The gate only narrows the shortcut path — it can't widen the buggy collapse onto any previously-working slice. The same condition generalizes to H2C and any future N-nozzle printer for free (no nozzle-count branching). Tests. Two new cases inTestExtractNozzleMappingFrom3MF:test_single_active_under_report_with_multi_group_falls_throughpins the #1825 regression (['Standard#1','Standard#0']+ group_ids{0,1}→{1:1, 2:0}not{1:1, 2:1});test_single_active_with_single_group_still_uses_shortcutpreserves the #851 behaviour (same stats + onlygroup_id=0→ shortcut still fires →{1:1, 2:1}). Existingtest_single_active_extruder_maps_all_slotsandtest_two_active_extruders_falls_throughstay green. Suites.pytest -n 30 backend/tests/unit/test_scheduler_ams_mapping.py backend/tests/unit/test_scheduler_filament_deficit.py backend/tests/unit/test_scheduler_filament_override.py backend/tests/unit/test_fallback_archive_mqtt_filament.py backend/tests/integration/test_archives_api.py backend/tests/integration/test_library_api.py272/272 green.ruff check backend/clean. Scope. Backend-only, parse layer. No DB migration. No new permission. No frontend change. The L/R-only badge limitation on 3+-nozzle printers (H2C tool-changer) called out in the report is a separate cosmetic follow-up and not part of this fix. - HMS Action buttons now reach the printer (#1830, H2D/H2C wrong-plate verification) — The HMS Actions feature shipped in #1743 looked correct at the publish layer but the firmware silently dropped the commands at the printer, so clicking "Stop printing", "Problem solved and resume", or "Ignore and resume" did nothing visible on the live H2D — the modal kept reappearing, the print stayed paused, and the route still returned
200 OK. Three independent bugs combined into one user-facing failure. (1) Wrong command shape for resume / stop.hms_resume()andhms_stop()sent the documented-but-not-actually-used{"err": <short>, "param": "reserve", "job_id": <subtask_id>, ...}shape that BambuStudio never produces. Bambu firmware rejects this silently — verified by injecting candidate shapes ondevice/<sn>/requestagainst a live H2D paused on a wrong-plate HMS: theerr-bearing shape held PAUSE → PAUSE for the full window, the plain{"print":{"command":"stop","param":"","sequence_id":"0"}}transitioned PAUSE → FAILED in 1.7s, the same plainresumetransitioned PAUSE → RUNNING in <2s. Fix: both helpers send the plain shape now, noerr, nojob_id, noparam:"reserve". (2)IGNORE_RESUMEmapped to the wrong command for paused prints. The original mapping dispatchedidle_ignorefor bothIGNORE_RESUMEandNO_REMINDER_NEXT_TIME.idle_ignoreis BambuStudio's "dismiss this warning" command and only works for non-pause warnings — verified against the H2D, idle_ignore on a paused print is silently rejected regardless oferr.hms_ignore()now branches onself.state.gcode_state == "PAUSE": paused → dispatch plainresume(which is what the button actually means on a paused print), running/idle → keepidle_ignorewith thetype=0/1persistence flag.DONT_REMIND_NEXT_TIMEon PAUSE degrades to resume too — the "don't remind" flag can't ride along on a resume but the user's clicked-action intent (continue printing) is honoured. (3) 64-bithms[]-array faults truncated to a non-matchingerr(#1830 §(1)). The hms[] parser at line 2740 built the short code asf"{(attr >> 16) & 0xFFFF:04X}_{code & 0xFFFF:04X}", discarding 32 of the 64 bits of the fault identifier. For codes whose full form is e.g.0C00_0300_0002_000C, the truncated0C00000Cdoesn't match what the firmware compares against inidle_ignore. NewHMSError.full_codefield carries the canonical hex identifier — 16 charsf"{attr:08X}{code:08X}"for hms[]-sourced faults, 8 charsf"{print_error:08X}"for print_error-sourced faults (which are already 32-bit). Catalog lookup tries the 16-char form first and falls back to the 8-char short code so existing entries keep matching. Frontend echoeserror.full_codeback asHmsActionBody.print_errorinstead of recomputing the short code; the schema's pattern relaxes to^[0-9A-Fa-f]{8}([0-9A-Fa-f]{8})?$to accept both lengths. (4) Masking failure — publish-success returned as printer-ack (#1830 §(3)).execute_hms_actionreturned True the moment the publish succeeded, so any of the three bugs above produced200 OKwhile the printer ignored the command and the modal kept popping. The/hms/execute-actionroute now snapshots(gcode_state, print_error, hms_errors count)before dispatch, awaitsHMS_ACTION_ACK_WAIT_SECONDS(default 2.5s, module-level so tests override), and returns502 "Printer did not acknowledge HMS action within 2.5s"if none of those moved. Every accepted HMS action mutates at least one of the three, so this is a clean signal. Empirical verification. A test harness ondevice/0948BB540200427/requestconfirmed each shape against the live H2D: a print sent with deliberately-wrong build plate raisesprint_error=0x05008051("Detected build plate is not the same as the Gcode file"), the printer entersgcode_state=PAUSE, and the new command shapes transition out correctly. The current Bambuddy code (before this fix) failed to act on every button. Tests.test_hms_actions.pyshape assertions rewritten —test_resume_is_plain_no_err_no_job_id,test_stop_is_plain_no_err_no_job_id,test_ignore_resume_dispatches_resume_when_print_paused,test_ignore_resume_uses_idle_ignore_when_not_paused,test_dont_remind_dispatches_resume_when_paused,test_dont_remind_uses_idle_ignore_type_one_when_not_paused,test_idle_ignore_accepts_16_char_full_code. NewTestHMSFullCodeclass intest_bambu_mqtt.pypins the parser contract —test_hms_array_path_populates_16_char_full_code,test_print_error_path_populates_8_char_full_code,test_hms_array_catalog_lookup_tries_16_char_first,test_hms_array_catalog_falls_back_to_8_char. New integration cases intest_printers_api.py—test_execute_hms_action_no_printer_ack_returns_502,test_execute_hms_action_accepts_16_char_full_code. The malformed-input test now covers 9- and 15-char rejections (the relaxed pattern accepts 8 OR 16, nothing in between).pytest -n 30 backend/tests/unit/services/test_hms_actions.py backend/tests/unit/services/test_bambu_mqtt.py backend/tests/unit/services/test_printer_manager.py backend/tests/integration/test_printers_api.pygreen (509 + 181).ruff checkclean. Frontendnpm run buildclean. Scope. No DB migration. No new permission. No new i18n key — the frontend toast on action failure already uses the existinghmsErrors.actionFailedstring, which now gets the more accurate "Printer did not acknowledge" message instead of "Failed to send action". TheHMSError.full_codefield defaults to""so old in-memory state surviving a backend upgrade (without an MQTT reconnect) degrades to the existing 8-char short code via the frontend's||fallback. - Queue Start/Stop permission gates + ASAP race + /reorder validator (#1625-followup) — Three issues caught in the post-merge audit of the unified-dispatch PR; all pre-existed on
devbut became more impactful once every print routes through the queue. (1) Start/Stop ownership gates.POST /queue/{id}/stoprequiredQUEUE_UPDATE_ALL(admin-only) andPOST /queue/{id}/startrequiredQUEUE_UPDATE_OWNwith no actual ownership check. Net result: Operators saw the Stop button in the queue UI but got 403 on click; meanwhile any _OWN holder could start anyone's queue item via direct API. Both routes now userequire_ownership_permission(QUEUE_UPDATE_ALL, QUEUE_UPDATE_OWN)with explicit ownership matching, mirroring/cancel. Stop is strict (mirrors cancel — _OWN cannot stop unowned items because stop is destructive and there's no claim semantic). Start is softer (preserves #1670's VP-import flow — _OWN can start NULL-owner items and claim ownership at click-time). FrontendQueuePage.tsxStart and Stop buttons flip fromhasPermission('printers:control')tocanModify('queue', 'update', item.created_by_id)so the FE matches the BE behaviour exactly. (2) ASAP TOCTOU race. Concurrent ASAP inserts to the same printer scope could both computeMAX(position)from before the other commits — in a non-empty scope, Postgres's row-level locks on the UPDATE shift serialise naturally, but the empty-scope path has no rows to lock, so both transactions inserted atposition=1(duplicate). The fix wraps the read+update in a Postgrespg_advisory_xact_lock(1625, scope_key)wherescope_key = printer_id or 0. Transaction-scoped, released automatically at commit/rollback, namespaced by 1625 so it can't collide with other advisory locks elsewhere in the codebase. Different printers don't contend. SQLite serializes writes implicitly so this is a no-op there. (3) /reorder duplicate-position validator.POST /queue/reordersetitem.position = reorder_item.positionin a loop without uniqueness validation — a buggy drag-drop client sending two items at the same position would leave the queue with ambiguous ordering (the scheduler'sORDER BY (printer_id, position)ties break by physical row order, making dispatch non-deterministic). Newmodel_validator(mode="after")onPrintQueueReorderrejects the payload at the schema layer with 422 + "Duplicate positions in reorder request: [N, …]" so the FE can surface the actionable detail. Uniqueness is enforced WITHIN the payload only — cross-printer reorders that intentionally share positions across different printer queues are a non-goal of the drag-drop UI, so this is the right scope. Tests. 7 new integration cases intest_ownership_permissions.py::TestQueueOwnershipPermissions: operator can start own item, operator cannot start others' item, operator can start unowned item and claims ownership (#1670 regression guard), operator can stop own printing item, operator cannot stop others' printing item, operator cannot stop unowned printing item, admin can stop unowned printing item. 2 new integration cases intest_print_queue_api.py::TestReorderEndpoint: 422 on duplicate positions with "duplicate" surfaced in the detail; 200 on unique positions with positions actually updated in the DB. Scope. No DB migration, no new permission, no i18n string change (existingnoStopPrint/noStartPrintkeys cover the new ownership-mismatch case verbatim). The advisory lock is Postgres-only and held inside the existing request transaction; SQLite path is unchanged. The /reorder validator runs before the DB session opens any rows. - AMS drying "Rotate spool" toggle no longer offered when any tray is threaded out — The drying popover's "Rotate spool during drying" toggle was always clickable, but rotation is mechanically impossible whenever any tray in the targeted AMS has its filament threaded out into the feed tube. The whole AMS rotates as one mechanism (all 4 spools turn together), so a single loaded slot locks the entire unit. The firmware enforces this (rejects with
dry_sf_reason=[3]"ConsumableAtAmsOutlet", surfaced as a 409 toast inroutes/printers.py:1754), but the user got to the failure only after clicking Start. The new mid-print drying path (above) makes it worse — the temptation to click rotate during a print is now reachable. Gate signal. Per-tray Bambustate:9= empty,10= spool present but NOT loaded into tube (rotation possible),11= loaded into tube (rotation impossible).PrintersPage.tsxderivestrayLoadedInThisAms = (targetAms?.tray ?? []).some(t => t.state === 11)using the existingamsDataarray (already cached against MQTT flicker). Whytray.state === 11and not the printer-leveltray_now. A first cut of this gate keyed ontray_now(the global slot currently feeding the toolhead) — but on the H2D, after a print finishes the firmware resetstray_nowto 255 (nothing actively feeding) while leaving the filament threaded into the feed tube. The tray'sstatestays at11in that idle-but-threaded condition;tray_nowdoes not. Reported live by a user with all AMS units showing loaded spools and the rotate toggle still active. Per-AMS isolation preserved. AMS-A having a tray in state 11 does NOT disable rotation on AMS-B — both can dry, and AMS-B's mechanism is still free. Submission clamp. The Start handler also clampsrotateTray: dryingRotateTray && !trayLoadedInThisAmsbefore mutating, so a sequence of "user enables rotate on AMS-B → user loads filament from a slot in AMS-B while popover is open → user clicks Start" can't leakrotate_tray=truethrough to the firmware. Without the clamp, the firmware-side rejection would still catch it, but the user would see a 409 toast for a mistake they couldn't have known about. Conservative on missing state. Trays withstate === undefined(older firmware that doesn't populate the field) are treated as not-loaded — rotation stays available and the firmware-sidedry_sf_reasoncheck remains the safety net. i18n. 1 new key (printers.drying.rotateUnavailableReason) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5355 leaves per locale, no English fallback. Tests. 9 new cases inPrintersPageDrying.test.ts::rotate tray gatecover: nulltargetAmsId(modal closed) → false; AMS id not in amsData → false; all trays empty (state=9) → false; all trays spool-present-not-loaded (state=10) → false (the case the user reported was firing incorrectly); any tray loaded (state=11) → true; per-AMS isolation (loaded AMS-A leaves AMS-B free); missingtrayarray → false; missingstatefield → false (conservative default-allow); submission clamp collapses to false when gate active, passes through when inactive. Vitest 41/41 green. Frontendnpm run buildclean. - Archives drag-and-drop overlay stuck after cancel (#1510, reported by @maikolscripts) — Cancelling a drag on the Archives page — by dragging back out of the browser window, releasing outside the page, or pressing Escape mid-drag — left the full-screen "Drop .3mf files here" overlay visible until the user refreshed. Cause. The old inline
handleDragLeaveonly hid the overlay whene.currentTarget === e.target(i.e. the dragLeave event fired on the wrapper itself, not a child). That condition was structurally safe for crossing internal element boundaries but rarely held for the three cancel paths above — drag-out-of-window fires dragLeave withtargetat the nearest child to the cursor; Escape and drag-abort fire no leave event at all on the wrapper. Fix. Moved the page-wide drop handling into the newusePageFileDrophook (also consumed by File Manager — see the linked Added entry). The hook checksrelatedTargetcontainment instead ofcurrentTarget === target, and adds document-leveldrop/dragend/keydown(Escape)listeners that only register whileisDraggingOver === trueso the cancel paths all reset uniformly. Three of the 13 new hook test cases pin the cancel paths explicitly so a future regression on any one of them fails its own case. Also moved the previously-hardcoded English "Drop .3mf files here" string inArchivesPage.tsx:3202to the existingarchives.page.dropFilesHerei18n key (which already had translations in all 11 locales) so the overlay localises correctly — same change of behaviour asarchives.releaseToUploadalready had. - File Manager list-view column headers misaligned with their body cells — Both the header row and each list row used the same
grid-cols-[auto_1fr_120px_100px_100px_100px_min-content]template — looked correct at the CSS level — but the two<div>s were sibling grids, not a shared grid, so each computedmin-contentfor the trailing actions column independently. The header's trailing column is an empty<div />→min-contentresolved to 0; body rows had 4–7 action icons →min-contentresolved to ~220px. With different trailing widths, the1frName column got a different amount of room in each grid, which pushed every fixed column to its right (Uploaded By,Type,Size,Prints) further right in the header than in the body. Visually the body cells looked shifted left of their column headers. Fix. Replaced the trailingmin-contentwith a fixed220pxin both the auth-enabled and auth-disabled grid templates (matching the comment that already documented the expected width of the 7-icon strip on sliced 3MFs). Updated the explanatory comment with the sibling-grid pitfall so the next person doesn't re-introduce it. No tests changed; the misalignment was purely visual (no DOM ordering / interaction changed), and the existing 51 FileManagerPage tests stay green. - MakerWorld import/resolve/status fail under API-key auth even when the owner has a Bambu Cloud login (#1777, reported by @Mx772) — The reporter (working on a browser extension that drives Bambuddy via
X-API-Key) noticed thatPOST /api/v1/makerworld/importandPOST /api/v1/makerworld/resolvereturned{"detail":"Downloading files from MakerWorld requires a Bambu Cloud login"}even when the key's owning user had a valid stored Bambu Cloud session, and the same imports succeeded from the web UI. Root cause is exactly the shape the reporter traced:require_permission_if_auth_enabledinbackend/app/core/auth.py:1414deliberately returnscurrent_user=Nonefor API-keyed callers — the comment at line 1408 makes this explicit and points atcloud.pyfor the resolver. The MakerWorld routes never got that resolver wired in, so_build_service(db, None)→get_stored_token(db, None)→ no token → the "requires Bambu Cloud login" branch fires regardless of what the owning account has set up. Same shape #1182 fixed for cloud slicer presets, and the canonical fix for non-/cloud/*routes is already in the codebase asresolve_api_key_cloud_owner(cloud.py:128-160) — used byslicer_presets.py:491andlibrary.py:3871. The MakerWorld routes were missing the wire-up. Fix: Three routes get the extraapi_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner)parameter —get_status,resolve_url,import_instance— and each resolvescloud_token_user = current_user or api_key_cloud_ownerbefore callingget_stored_token/_build_service.import_instanceadditionally usescloud_token_user.idfor theowner_idargument tosave_3mf_bytes_to_library(which translates toLibraryFile.created_by_id), so library rows imported via API key are now attributed to the key's owner instead of staying NULL./recent-importsis unchanged — it only usescurrent_useras a permission gate (_ = current_user) and never touches the cloud token. The fix preserves fail-closed semantics for keys without thecan_access_cloudflag:resolve_api_key_cloud_owneralready fences onapi_key.user_id is not None and api_key.can_access_cloud(cloud.py:158), so a key with only the per-route scope (can_read_status/can_manage_library) still surfaces the "requires Bambu Cloud login" error path — no new auth gap. Two scope fields the API key needs: the per-route scope (MAKERWORLD_VIEW→can_read_status,MAKERWORLD_IMPORT→can_manage_libraryper_APIKEY_SCOPE_BY_PERMISSIONincore/auth.py) AND the orthogonalcan_access_cloudflag (separate column on theapi_keystable). The fix doesn't change that surface — it just stops dropping validcan_access_cloud=Truekeys on the floor. Tests: 6 new cases inbackend/tests/integration/test_makerworld_apikey_auth.pypinning the full surface — API key withcan_access_cloud=True+ owner-has-token →/statusreportshas_cloud_token=True,/resolvebuilds the service with the owner User (asserted on the_build_servicemock's call args),/importsucceeds end-to-end and the resultingLibraryFile.created_by_idmatches the API-key owner; API key withcan_access_cloud=False→ status still reportshas_cloud_token=False(no widening) and import-row'screated_by_idstays NULL; JWT-authenticated parity check confirms the existing user-session flow is unchanged by the addedDepends. 6/6 new tests green; full backend suite (6157 tests) still green; ruff clean. No frontend change, no DB migration, no new permission, no new dependency. The reporter's browser extension and any other API-keyed Home Assistant / automation integration unblocks immediately on next deploy. - PrintModal printer picker no longer offers a printer between dispatch-accept and PRINT_START (reported off-list by a corporate user running multi-operator farm shifts) — Operator picks a printer in the reprint modal, hits Send, Bambuddy accepts the dispatch and begins FTP upload + sending the print command. The printer hasn't reported
gcode_state=RUNNINGyet — it's still IDLE on its own MQTT status. A second operator opening the modal during this window sees the same printer as available and submits a second job. The backend correctly rejects the second submit with HTTP 409 (background_dispatch._dispatchrejects when_queued_jobsor_active_jobsalready holds the printer_id), so no double-print is possible, but the operator only finds out after they click Send — wasted minutes per attempt on a busy floor. Root cause:PrinterSelector.tsx::isPrinterBusyconsulted onlyPrinterStatus.stateagainstAVAILABLE_STATES = {IDLE, FINISH, FAILED}. PRINT_START is the only signal that flips the printer out of IDLE, and there's a real wall-clock window (upload time + print command + firmware ack) between dispatch acceptance and that flip. The dispatch-queue state — already broadcast as a WebSocketbackground_dispatchpush includingdispatched_jobs[].printer_idandactive_jobs[].printer_id— was being consumed byToastContextfor the progress overlay but never read by the picker. Fix: newfrontend/src/hooks/useDispatchedPrinterIds.tsexposesSet<number>of printer_ids with a queued or active dispatch, populated from the samebackground-dispatchwindow event the ToastContext listens for. Module-level singleton +useSyncExternalStoreso everyPrinterSelectorinstance sees the same snapshot and a modal opened mid-batch picks up the latest state without a refetch. Reference-stable snapshot (size + membership check) keepsuseSyncExternalStore's Object.is comparison from re-rendering on every WS push that doesn't change the set.PrinterSelector.tsx::isPrinterBusyORs the set into the existing connected/state check — printer disabled the instant dispatch is accepted, re-enabled when the dispatch finishes (or fails) and disappears from the next state payload.getPrinterStateLabelreturns"Dispatching..."for the badge so operators see the in-flight state instead of a misleading "Idle" on a now-disabled card. Hardcoded English label is consistent with the existing labels in that function ("Idle","Printing","Paused"are all hardcoded, no i18n key). What this is NOT: a backend change (the reservation Mike asked about already exists atbackground_dispatch.py:283-290); a behaviour change foradd-to-queue/edit-queue-itemmodes (those don't setdisableBusy=true, so the busy-OR remains dormant for the card click handler — the badge label still flips, which is informative); a guarantee against the WS-not-yet-connected race (a fresh page load that opens the modal before the WS initial-state push lands still sees an empty set for ~1 frame; same race as today, much shorter window). Tests: 8 new cases inuseDispatchedPrinterIds.test.tspin the contract — empty initial set, picks updispatched_jobsprinter_ids, picks upactive_jobsprinter_ids, unions both lists, clears when subsequent event reports zero jobs, ignores non-numericprinter_id(defensive against payload drift), reference-stable snapshot when content doesn't change, shared state across hook instances. Existing 84 PrintModal + PrinterSelector cases still green — the new code path is dormant until abackground-dispatchwindow event fires, which existing tests don't trigger.npm run buildclean, ESLint clean.
Security
- Bumped two frontend dev-tooling dependencies with denial-of-service advisories (GHSA-3jxr-9vmj-r5cp, GHSA-52cp-r559-cp3m) —
brace-expansionandjs-yaml, both pulled in transitively byeslint(viaminimatchand@eslint/eslintrc), were flagged bynpm audit. They are build/lint-time tooling only and are not part of the shipped app, so no running Bambuddy install was ever exposed.npm audit fixcouldn't move eslint to the patched versions on its own, so they're pinned to the fixed releases through the existingoverridesblock infrontend/package.json(brace-expansion ^5.0.7,js-yaml ^4.3.0).npm auditnow reports zero vulnerabilities and eslint still runs clean. - Raised the Docker image's pip floor to 26.1.2 (PYSEC-2026-196) — The image already upgraded pip before installing requirements, but the floor was
pip>=26.1while PYSEC-2026-196's fix is specifically 26.1.2 (the related PYSEC-2026-2875/2876 are fixed in 26.1).--upgradegrabbed the latest in practice, but the loose floor could resolve 26.1.0/26.1.1, which are still vulnerable; the pin now matches the advisory exactly. Build tooling only — pip is not part of the running app. - Bumped
linkify-itanddompurifyto their patched releases (GHSA-v245-v573-v5vm, GHSA-c2j3-45gr-mqc4) —npm auditflagged both against the production dependency tree, and the Frontend Security CI job fails on any fixable high-severity finding there.linkify-it5.0.1 → 5.0.2 (high, CVSS 7.5) carries a quadratic-complexity denial-of-service in themailto:validator's scan loop. It reaches Bambuddy only throughprosemirror-markdownbundled inside@tiptap/pm; the rich-text editor's own autolinking useslinkifyjs, a different package that is not affected. Nothing underfrontend/src/importsprosemirror-markdownormarkdown-it, and neither string appears in the production bundle — the vulnerable code is tree-shaken out and never reaches a browser, so no running install was exposed.dompurify3.4.11 → 3.4.12 (low) letsCUSTOM_ELEMENT_HANDLINGbypass anafterSanitizeElementshook for allowed custom elements. DOMPurify is shipped (MakerWorld summaries, project notes, the project-page modal), but Bambuddy never setsCUSTOM_ELEMENT_HANDLING— the default rejects custom elements outright — and registers noafterSanitizeElementshook, so the bypass has no precondition to stand on; the project-page modal additionally passes a strictALLOWED_TAGS/ALLOWED_ATTRallowlist. Both patched versions already satisfy the ranges their parents declare, so this is a lockfile-only change: nooverridesentry was needed andfrontend/package.jsonis untouched.npm auditnow reports zero vulnerabilities across the production tree,npm run buildis clean, and all 2423 frontend tests pass.
[0.2.4.9] - 2026-07-07
Added
- Spool labels: scannable QR on 203 dpi thermal printers + monochrome mode (#1870) — The 40 × 30 mm box label rendered its QR too densely for low-res thermal printers, so the modules bled together and wouldn't scan. The roomy layout now gives the QR a 12 mm minimum size and label QRs use
ERROR_CORRECT_L(chunkier modules, same payload), so every template stays scannable. Also adds a Monochrome (black & white printer) option that drops the colour swatch and widens the text column, with the colour still carried by the hex-code line. Threaded through the renderer, route, API client, and modal, translated in all 11 locales. - Preheat & heat-soak before queued prints — per-filament chamber targets + airduct flap control (#1468) — A new scheduler stage heats the bed (and the chamber, on capable printers) and holds temperature before each queued print starts, giving engineering filaments the heat-soak they need for adhesion and warp control (M191 is firmware-ignored, so this only works at the orchestration layer). Per-item Inherit/On/Off override, per-filament chamber targets (max across loaded slots), three hardware tiers (active heater / sensor-only / bed-only), and automatic airduct-flap switching (heating vs cooling). Default off — existing installs are unchanged.
- API keys:
can_manage_maintenancescope for HA-style automations (#1832 follow-up) — Carves the MAINTENANCE create/update/delete permissions out of the API-key admin denylist so a Home Assistant automation can log "cleaned nozzle" or reset a counter via an API key without granting broader printer control. New per-key scope + Settings toggle + badge (11-locale i18n); existing keys migrate to off, so no upgrade silently widens scope. - Indonesian Rupiah (IDR) currency support (#1869) — Adds IDR (Rp) to the supported currencies under Settings → Cost Tracking.
Fixed
- Filament Track Switch (FTS) on H2C fed the wrong filament for prints on the "other" nozzle (#2186) — The backend dispatch mapping hard-filtered candidate trays to the requested extruder, so a print targeting one nozzle couldn't use the correct spool loaded in the other nozzle's AMS (which the FTS routes across) and fell through to a same-type wrong-colour spool. The mapping now reads
fila_switch.installedand skips the per-nozzle filter when an FTS is present, so the right spool is matched by colour and the FTS routes it to the target nozzle. Single-nozzle printers are unaffected (nonozzle_id, no FTS). - Queued prints never dispatched to FINISH-state printers when "Require plate-clear confirmation" was disabled (#1865) — The scheduler read the setting with a
Truedefault while the schema and the whole frontend default itFalse, so installs that never saved it enforced a plate-clear gate the UI showed as off — a finished printer never dispatched the next job and there was no UI control to clearawaiting_plate_clear. The scheduler now defaults the setting toFalse, matching the schema and the toggle. - Light theme: low-contrast washed-out text on status banners and coloured badges, app-wide (#1909) — The app was built dark-first, so hundreds of hardcoded light-shade Tailwind text/icon utilities had no
dark:variant and applied in light theme too — washed-out text on pale tints and white cards. Each now has a theme-aware pair (a readable darker shade in light theme, the original pinned todark:), so dark theme is unchanged. ~100 files; the self-correctingbambu-*palette and the dark-only SpoolBuddy kiosk were left untouched. - Sponsor toast ignored its 14-day cooldown and re-fired on every fresh browser session (#2477) — The cooldown anchor was only persisted when the user clicked the toast's "View supporters" CTA, so a toast that was seen but not clicked recorded no state and re-showed on every new session. The toast is now recorded as shown the moment it renders, so being displayed arms the cooldown; clicking the CTA stays optional.
- Windows: fresh install failed to start — nothing listening on :8000 (#2474) — On a clean Windows 10 box, greenlet failed to load (
vcruntime140_1.dllmissing — the embeddable Python ships onlyvcruntime140.dll), soinit_db()crashed and uvicorn never bound the port while the NSSM service still showed running. The installer now stagesvcruntime140_1.dllandmsvcp140.dllnext topython.exeat build time. - Virtual Printer "bind interface" dropdown was empty on macOS — Interface enumeration only routed Windows through psutil; macOS fell into the Linux-only ioctl branch (
SIOCGIFADDR/SIOCGIFNETMASK) and returned an empty list. All non-Linux platforms now use the cross-platform psutil path. - macOS native install failed with Homebrew / venv permission errors — The installer mixed root-only steps with steps that must not run as root (brew refuses to run as root; a root-owned venv can't be managed by the launchd agent). The macOS path is now fully rootless — refuses
sudo, defaults to~/bambuddy, and dropssudofrom the download/venv/frontend/env steps. Linux (service user + systemd) is unchanged. - External camera "connection lost" when the snapshot URL served a non-JPEG image (#1902) — HTTP-snapshot cameras serving PNG/WebP/BMP tore down the MJPEG stream because every part is labelled
image/jpeg. Non-JPEG stills are now transcoded to JPEG via OpenCV; genuine JPEGs keep a byte-for-byte fast path, and undecodable responses fall back with a single warning instead of a per-frame log flood. - Per-user Notifications page unreachable from the sidebar (#1901) — A sidebar-ordering refactor (#1673) dropped the
notificationsnav entry and its permission mapping while keeping the visibility gate that references it, so the page was reachable only by typing the URL. Both are restored, with comments so it isn't dropped again. - Virtual Printer FTP uploads silently truncated under uvloop (#1896) — Native installs auto-selected uvloop, whose SSL layer can drop buffered data when the client closes without a TLS close_notify, so a corrupt
.gcode.3mfcould be acked226, archived, and pushed to the printer. Fixed on two layers: pin--loop asyncioon every native launch path, and validate that a received.3mfopens as a ZIP before replying226(truncated files answered426, never archived or forwarded). - API keys could not manage Projects (#1893) — Every project mutation returned a generic
403for any API key. A newcan_manage_projectsper-key scope (Settings toggle + badge, 11-locale i18n) covers create/update/delete; existing keys migrate to off. Same regression class as archives (#1888) and library (#1832). - Auto-drying stopped a manually started AMS dry after exactly 30 minutes (#1892) — The already-drying branch applied an unreliable humidity-based early-stop (RH reads ~15–20 % within minutes of heated air even with wet filament), pinned to the 30-minute floor, which also truncated Bambuddy's own preset-duration dries. The humidity early-stop is removed — a running dry now runs to its configured duration (firmware stops it); scheduling stops are unchanged.
- WebSocket auth failure caused an endless token-mint reconnect loop — When the ws-token mint failed (typically a logged-in user whose group lacks
WEBSOCKET_CONNECT→403), the hook opened a tokenless socket, got closed4401, and reconnected every 3 s — hammering/auth/ws-token. Mint failures are now classified:401/403stop the hook (degrade to REST polling), network/5xxstill reconnect, and an unmount-race reconnect is guarded. Adds a group-editor hint explaining the permission rather than auto-granting it. - A transient load-time error could discard a valid stored login token (#1889) — On mount, any failure validating the persisted "Remember Me" token cleared it, so a brief backend-not-ready hiccup during page load (e.g. right after a container restart) bounced the user to login with no way to recover. Validation now retries transient failures (up to 3 attempts) and only discards on a definitive
401. - Smart plug cut power when a print restarted, ignoring per-plug cooldown (#1890) — The queue "auto off after this job" trigger used a second inline implementation that hardcoded a 50 °C / 600 s cooldown and powered off on timeout regardless of print state, cutting power mid-print on a touchscreen reprint, and the tasks were uncancellable. Consolidated into the plug's configured, cancellable strategy, guarded by
is_print_active()so no path powers off during a loaded print. - API keys could not delete or edit archives (#1888) —
DELETE /archives/{id}rejected every API key with a generic admin-denied403. A newcan_manage_archivesper-key scope moves the create/update/delete permissions off the denylist (PURGE stays admin-only); existing keys migrate to off. Settings toggle + badge, dialect-agnostic migration verified on SQLite and Postgres 17. - PVA-for-support intent lost when re-slicing a source 3MF (#1881) — Three bugs on the PLA-model + PVA-support flow: a support-only slot was treated as "unused" and overwritten with slot 1's PLA, support filaments were stripped from unsliced archive cards, and the picked process preset shipped
enable_support=0. Support slots are now read from project settings and unioned into the unused-slot set, all configured filaments surface, and the source 3MF's support settings are overlaid onto the process preset before--load-settings. - Bambu Studio couldn't see or reconnect to the VP after a Mac sleep/wake (#1872) — A drain timeout on the report topic was caught as a non-
OSError, so the push loop never evicted the zombie writer until the kernel's ~2 h keepalive. On drain timeout the writer is now closed and re-raised asBrokenPipeError(evicted on the same tick), and TCP keepalive is tightened (KEEPIDLE/KEEPINTVL/KEEPCNT) to ~2 min dead-peer detection. - Non-proxy VP camera passthrough was dead for A1 / P1 targets (#1868) — The passthrough hardcoded port 322 (RTSPS), but A1 / A1 Mini / P1P / P1S use Bambu's chamber-image protocol on port 6000, so those targets got a 322 listener with no upstream (OrcaSlicer Liveview
[2:-10061]). The port is now chosen from the target printer's model via the same source of truth as the camera route. - Editing a queue item assigned to "Any of model X" showed a blank printer selector — The three model-mode props were gated behind
!isEditing, which hid the mode toggle, model dropdown, and location filter (and the printer list was gated on printer-mode), leaving the selector empty. The gate is dropped so the target model/location can be changed without delete + re-queue. - Finish photo captured the wrong (swapped) plate on A1 / A1 Mini (#1867) — A1 Mini firmware skips the stage-22 pre-capture, so the fallback fired at
FINISH— after Bambu Studio ran the user's End G-code (e.g. a SwapMod plate swap). Alayer_num >= total_layer_numedge trigger now fires the pre-capture the moment the last layer completes, on every variant, guarded by the existing one-shot. - Spoolman didn't split mid-print usage across an AMS backup switch (#1793) — A same-material runout switch mid-print charged the whole slot to the origin spool and double-credited the backup via the remain-delta path. The segment math is now shared by both inventory backends so mid-print switches attribute identically, and the remain-delta fallback skips trays the split path already covered.
- P1S / P1P showed a permanent "Door Closed" badge (#1866) — P1S has an enclosure door but no hall sensor for it, and P1P has no enclosure at all, yet both rendered the green badge from a status bit that stays 0. The door-badge whitelist now covers only models that actually ship a door sensor (X1 family, X2D, P2S, H2 family).
- Custom Bambu Cloud filament presets showed as "Generic" (#1815) — The singular slicer-setting GET/DELETE calls omitted the
?version=param Bambu Cloud requires and returned HTTP 400, so the resolver swallowed it and fell back to a generictray_info_idx(BambuStudio's AMS panel then showed "Generic PLA"). The param is now sent on those calls, restoring custom cloud-preset lookup across the delete/update and preset-resolver surfaces. - Cancel during queue dispatch didn't cancel — the print started anyway (#1853) — A check-then-act race in
_start_print(plus a WAL writer lock held through the FTP upload) let the scheduler's stale in-memory write overwrite a user's cancel, and causeddatabase is lockedcontention. Fixed with an atomic pending→printing CAS, an early re-check-and-bail, and committing before the FTP block so cancels and the sensor recorder stop queueing behind the scheduler. - "Inject auto-print G-code" checkbox couldn't be ticked on single prints (#1852) — A
useEffectreset the checkbox whenever quantity ≤ 1 in create mode, even though it renders whenever G-code snippets are configured — so a single-print user's click was immediately reverted. The quantity clause was dropped; the scheduler already readsgcode_injectionper item regardless of batch size. - HMS wrong-plate "Ignore" didn't ignore, and the action buttons read as inert badges (#1869) — Three compounding bugs on a wrong-plate HMS:
IGNORE_RESUMEsent a plainresume(so detection re-fired 1–2 s later) instead of BambuStudio's decimal-errignore command; the button hover class was a non-literal template Tailwind's JIT couldn't compile; and ack-detection false-502'd on the transient re-pause. Fixed — the correct ignore command shape, static button styling with disabled/spinner states, and ack-detection via the last-message timestamp. - Slicer auto-pick could silently land an incompatible filament, and hid the real CLI error (#1851) — The actual "not compatible with printer" diagnostic was discarded in favour of Bambu Studio's catch-all "input preset file is invalid" placeholder, and the picker used a soft mismatch penalty rather than a hard skip, so one bad pick propagated across every unused slot. The real
[error]line is now surfaced, and incompatible presets are hard-skipped whenever a compatible one exists. - Uncataloged HMS faults that carry firmware actions were hidden (#1840) — Fault visibility gated on catalog membership, so an actionable H2C fault missing from the bundled 853-entry catalog never rendered — no pip, count, panel, or action buttons. The gate now keeps
cataloged OR has-actionsfaults (still filtering junk echoes), with an "unknown HMS code" fallback label in all 11 locales. - First-layer notification photo showed pre-print calibration, not the actual print (#1837) — Bambu printers tick
layer_numduring calibration (homing, bed levelling, purge), so a bare2 ≤ layer_num ≤ 5gate fired the notification minutes early with a photo of an empty plate. It now waits forgcode_state == RUNNING(and the printing sub-stage) before firing, and widens the trigger window so a deferred edge still lands. - Administrators didn't gain new permissions on upgrade + Pipelines runs-dashboard polish — Upgraded Administrator groups only received permissions listed in one-off backfill blocks, so any newly-added permission (most recently
printer_sensor_history:read, which 403'd the Sensor History charts) silently stayed missing.seed_default_groups()now syncs Administrators to every current permission on startup (additive only; custom permissions preserved). Also replaces the Pipeline/Status/Target native<select>filters on the runs dashboard with themed dropdowns and fixes areact-hooks/exhaustive-depswarning.
[0.2.4.8] - 2026-06-28
Added
- Lower sponsor-prompt thresholds so the toast fires for typical new installs (
257b9e2c) - SSO autologin + disable local username/password login (#1589, requested by @einstux) (
549d3216) - "Auto-add unknown RFID spools" toggle + global confirmation modal (#1764) (
9f9c1775) - Backup-aware filament deficit check, colour-strict (#1762) (
29a5abd9) - Drag-reorder for grouped queue items; collapsed batches no longer block adjacent rows (
7b06ebd7) - Per-filament humidity threshold for auto-drying + alarms (#1605, requested by @thenewguy) (
7e5eff14) - Per-printer Maintenance Mode toggle (#1476, requested by @IndividualGhost1905 / Ferdi SEVER) (
ee270922) - In-app sponsor-toast at earned milestones (
e761e092) - Prominent sponsor banner on Settings → General (
5c16ef3e) - Heater history (nozzle / bed / chamber) tracked + per-tile chart-icon overlay opens history modal (
d1d16659) - AMS Filament Backup status badge + toggle on the printer card; "Prefer lowest" actually picks the lowest spool (#1766, reported by @biduleman) (
99c6949b) - Updated printer card UI for structure and readability (#1661) (
6fa74be4)
Changed
- Printer card AMS row: external tray height matches regular AMS slots (
00e4aed7)
Fixed
- Assign-spool picker note now visible on mobile (#793 follow-up, reporter @EmcetPL) (
b2b04fc4) - API keys with Manage Library permission can rename / delete / move library files (#1832, reporter @MorganMLGman) (
4c795636) - Forecasting groups spools by colour + Forecast UI rework (#1814, by @Keybored02) (
3cbdba0c) - False-positive "Print Stopped" notification on reprint after MQTT reconnect (#1807, reported by @volodymyr-doba) (
5e008744) - Unknown-tag modal no longer pops for slots with no RFID (
8b72b305) - SpoolBuddy "Assign to AMS" preserves the user's slicer preset instead of pushing Generic (#1815, reported by @Bgabor997) (
cd5a02c0) - H2S active-tray highlight no longer stuck on AMS slot 1 during external-spool prints (#1822, reported by @ojimpo) (
2bd2bce3) require_previous_successno longer permanently blocks a printer's queue after a failure (#1818, reported by @jmassardo) (ba7af59b)- Archives "Step 4" docs link no longer 404s (#1812, reported by @Spanholz) (
c52aba66) - H2C nozzle pick from Bambu Studio preserved on dual-nozzle rack variant + VP slicer-field intake (#1780, reported by @mkoreen) — Race window bumped to 5s with retroactive stamp; VP intake key mismatch corrected; rack-swap nozzle pick forwarded to dispatch. (
71b0575f,30c2e263,b6916055) - Connection diagnostic no longer reports false camera-port warning on A1 / A1 Mini / P1 (#1799 closing #1798, by @lesbass / Stefano Maffeis) (
8e99b0c8) - Print-complete notification no longer drops the finish photo when the FINISH-state fallback fires (#1790, reported by @needo37) (
a9bf6f1f) - Chamber-fan badge hidden on open-frame Bambu printers (
568f220a) - In-app "Install Update" on Windows installer switched to release-asset update flow (
b7ff72d8) - Mid-print AMS Backup spool-switch correctly splits weight instead of crediting all to the second spool (#1771, reported by @biduleman) (
a70c2a2d) - AMS history modal respects theme background variant in stats modal (
55510871) - Completion notification scoped to printed plate on multi-plate 3MFs (#1785) (
964015de) - Docker installer escalates on EACCES instead of failing on
/opt/bambuddy(#1774, reported by @jmoore-skild) (03d09238) - Archive thumbnails rendered server-side when the sidecar slice skips them (#1759, reported by @VID-PRO) (
d2232e02) - Post-#1661 printer-card cleanup — test fixtures + hover-card fly-in removal (
0128869d) - Local Presets page: deleted row optimistically removed instead of staying visible until refetch returned (
0eed9865) - SpoolBuddy inventory search matches spool ID, slicer filament name, and storage location (#1738, reported by @shaddowlink) (
355d08a8) - Sidebar entries for Files / Archives / Queue no longer hidden from non-admin users with granular
*_readaccess (#1755, reported by @knifesk) (7f150886) - Push notification for "Printer offline" actually fires (#1752, reported by @saint-hh) (
2cbbd1ee) - Auth preserves the original URL across login + OIDC round-trip (#1750) (
ed1683fe) - Archives backfill NULL
created_at+ tolerate NULL in response (#1732) (8faaeb96)
Security
- Floor pins for pydantic-settings ≥2.14.2 + msgpack ≥1.2.1 (
458bfa15) - Backend dependency security floor bumps + 422 constant rename (
580f42c1) - dompurify 3.4.10 → 3.4.11 (GHSA-cmwh-pvxp-8882, moderate) (
11227f65) - Vite 7 → 8 + plugin-react 5.2 (major bump) (
f7620406) - Frontend dependency bumps (
000af683) - Printer secrets restricted to update-authority callers (
8283b175)
[0.2.4.7] - 2026-06-14
Added
-
Bambu Lab A2L support (#1684) — Internal model code
N9, serial prefix26A19(5 chars, same shape as H2C's late31B8B). Capabilities resolved from BambuStudio'sresources/profiles/BBL/machine/Bambu Lab A2L.jsoncross-checked against Bambu's official A2L specs page: linear rail, single FDM extruder + integrated cutter/plotter head (the BambuStudiouse_double_extruder_default_texture: trueflag covers the dual TOOL HEADS, not dual filament extrusion — A2L must NOT route AMS to the deputy slot or firmware rejects with 07FF_8012). Specs page also confirms NO Ethernet (Wi-Fi 2.4 GHz 802.11 b/g/n only),Low-Rate-Kameraon the chamber-image protocol (port 6000, NOT RTSP:322), no heated chamber. Registry updates:PRINTER_MODEL_MAP+PRINTER_MODEL_ID_MAP+LINEAR_RAIL_MODELSinutils/printer_models.py;MODEL_TO_API_KEY+API_KEY_TO_DEV_MODEL+API_KEY_TO_WIKI_PATHinfirmware_check.py(wiki path follows the established/en/a2l/manual/a2l-firmware-release-historypattern; the existing 404 handling in_fetch_all_versions_from_wikimakes this safe to ship before Bambu publishes the page);VIRTUAL_PRINTER_MODELS+MODEL_SERIAL_PREFIXESinvirtual_printer/manager.py(prefix26A19Awith the same revision-letter padding as X2D's20P90A);MODEL_PRODUCT_NAMESinvirtual_printer/mqtt_server.py;mapModelCode+ Add-Printer / Edit-Printer model dropdowns inPrintersPage.tsx(new "A2 Series" optgroup);mapModelCodeinSpoolBuddyAmsPage.tsx. Camera and dual-nozzle code paths need no edits:supports_rtsp()correctly falls through to chamber-image for A2L becauseN9is neither in the internal-code RTSP set nor does the display name match the X1/X2/H2/P2 prefix tuple;is_dual_nozzle_model()correctly returns False because A2L is not inDUAL_NOZZLE_MODELS. The cutter/plotter capability surfaces in MQTT push fields Bambuddy doesn't yet model; ignored for v1, will surface as a follow-up only if a real-world A2L bundle reveals a confusing UI state. Tests: 12 new cases intest_printer_models.py::TestA2LModelpinning every dimension — rod type, model-id round-trip, both ethernet directions, both camera-port directions, the explicit non-dual-nozzle guard (regression guard for the BambuStudio profile flag misread), set membership inLINEAR_RAIL_MODELSand exclusion fromCARBON_ROD_MODELS/STEEL_ROD_MODELS. -
One-shot
device.*identification probe in MQTT push parser (#1684 enabler) — Adding support for a new Bambu printer model needs the internal model code the firmware sends in MQTTdevice.dev_model_name(e.g. A1 isN2S, H2C isO1C, X2D isN6). The field arrives on every push but Bambuddy never logged it, so even a debug-enabled support bundle from a new-model user (A2L on #1684 was the case that surfaced this) gave us no way to identify the model —get_versionwas also missing because the printer disconnected right after the request topic subscription, which is a separate firmware quirk. Fix: at the top of the existingdevice.*parsing block inbambu_mqtt.py, emit one INFO log per client session dumpingdev_model_name/dev_product_name/dev_id/project_nameif any are present; otherwise fall back todevice.keys()so a future Bambu rename (e.g.model_namewithout thedev_prefix) still surfaces. INFO level so the line lands in every support bundle, not just debug-enabled ones; one-shot via a_device_id_loggedflag matching the existing_nozzle_fields_loggedpattern at line 2095 — no spam at every push_status. 3 unit tests inTestDeviceIdentificationProbepin the one-shot behaviour, the known-id-field path, and the keys-fallback path. Fulltest_bambu_mqtt.pysuite 281 / 281 green; ruff clean. Once this ships, a new-model issue self-resolves from the first bundle — no second round of "please enable debug and reupload" required. -
Re-print / Schedule modal: cross-extruder AMS slot picks on dual-nozzle (#1722, reported by @privatsturm) — On a dual-nozzle setup (e.g. H2D with AMS A+C wired to the left extruder and AMS B wired to the right), the per-filament slot dropdown in the Re-print and Schedule modals used to hide every slot whose extruder didn't match the filament's slicer-assigned nozzle. A filament the slicer had assigned to the left extruder would only let the user pick from A or C; a right-assigned filament could only pick from B. Users who'd intentionally loaded the required filament into the "other" AMS — for example, AMS B (right side) carrying a colour the slicer had planned to print on the left — couldn't select it, even though the printer can physically run that AMS through its wired extruder. Three slice-output diffs (BambuStudio Desktop, OrcaSlicer Desktop, Bambuddy sidecar) all produced identical filament_map values for the same source 3MF, so the slicer wasn't the source of the asymmetry — Bambuddy's UI filter was. Behaviour: every loaded slot is now offered for every filament row in the Re-print and Schedule modals' specific-printer flow, regardless of which extruder it's wired to. The L/R badge on the filament row stays as a visual hint to what the slicer planned; the dropdown now trusts the user to pick based on their physical setup. Single-nozzle printers and FTS-equipped setups are unchanged — both short-circuited the filter already and continue to. Printer firmware accepts or rejects the resulting
ams_mappingat start-print, so a physically-impossible pick fails loudly rather than silently. Implementation:FilamentMapping.tsx:248-254carried a guardf.extruderId === item.nozzle_idon the slot dropdown'sloadedFilamentsfilter; the guard is now removed. The single-nozzle and FTS short-circuits stay. Tests:'still applies the per-nozzle filter when FTS is null'flipped to'offers cross-extruder slots in the dropdown without FTS (#1722)'— same scenario (no FTS, AMS 0 on right, filament asking for left), but now asserts both slots ARE listed. The FTS-installed case (#1162) and the rest of the FilamentMapping suite stay green. Backend untouched; no schema, no i18n. 5/5 FilamentMapping vitests green; 1043/1043 full component sweep green; frontend build clean. -
Support bundle now includes redacted cached push_status per connected printer — The existing support bundle (
GET /support/bundle) shippedsupport-info.json+bambuddy.log— useful for triage, but missing the one thing that consistently blocks per-model work: the raw shape of the printer's MQTT push_status payload. Bambu firmware ships per-model config in a different shape for every family — AMS Backup detection was deferred in85fbd7fcbecause the H2D's bit-26 ofprint.cfgdoesn't translate to the X1C / P1S / P2S layout and we had no ground-truth samples to map them; the same gap surfaces every time avt_tray/vir_slot/mappingshape varies across firmware (the P2Stray_nowfix, the H2Dvir_slotparsing, the round-5vt_trayoverlay fix from #1622 last week all needed wire samples to land). What's new: the bundle now contains apush-status/printer-{i}.jsonfile per connected printer, indexed againstsupport-info.json["printers"]. Each file carries{model, firmware_version, captured_at, raw_data}whereraw_datais the live cached push_status fromBambuMQTTClient.state.raw_data. Disconnected printers (no MQTT state, orraw_dataempty) are skipped — there's nothing to capture and an empty file just adds noise. Redaction (two-pass): a structural pass via the new_redact_raw_push_statushelper drops user-private top-level keys anywhere in the tree (subtask_name,gcode_file,gcode_file_prepare_percent,subtask_id,task_id,project_id,design_id,profile_id,model_id,gcode_state) — Bambu's per-print filename/cloud-ID surface — and rewrites everynet.info[*].ipentry to"0.0.0.0", mirroring the LAN-topology leak fixed for the virtual-printer bridge in #1429. What's deliberately preserved:print.cfg,print.option,ams.*,vt_tray,vir_slot,mapping,ams_extruder_map, hardware fields (nozzle_diameter, temperatures, layer counters). These are the fields per-model work depends on. The structural pass then runs throughsanitize_log_contentwith the same DB-derivedsensitive_stringsmap the log path uses (printer names, serials, IPs, access codes, usernames, Bambu Cloud email) — belt-and-suspenders against any user-named string that leaked into a tray UUID or a sub-brand field. The redactor returns a NEW dict and never mutates the livestate.raw_data(the dispatcher reads it on every tick; mutation would race the next push). Why always-on instead of opt-in: the bundle endpoint is already gated on "debug logging must be enabled" — generating the bundle is an explicit user act, the file downloads to the user's machine before they choose to send it, and forcing a second toggle adds friction without changing the threat model. Once a handful of bundles arrive from new-model users we'll have what we need to unblock AMS Backup awareness in the print-queue deficit check, plus future per-model shape variance. Tests: 5 new unit cases intest_support_helpers.py::TestRedactRawPushStatuspin the contract — drops the 9 user-private keys, rewritesnet.info[*].ipwhile preservingmasksiblings + siblingnetkeys, preservesprint.cfg/ams/vt_tray/vir_slot/mapping/ams_extruder_map, does not mutate input, handles non-dict input gracefully (returns{}for None / list / str). Full support test surface 79/79 green (test_support_helpers.py+test_support_api.py); full backend suite 5937/5937 green with-n 30; ruff clean across the backend; frontend untouched but rebuild + i18n parity confirmed clean perfeedback_run_all_ci_checks. No migration, no new i18n keys, no schema changes, no frontend changes. -
Windows installer build pipeline scaffolded — Lays down the infrastructure for producing a self-contained Bambuddy Windows installer
.exethat doesn't require Python, Node, or any other runtime on the target machine. The installer ships an embedded Python 3.13 distribution (matching the Dockerfile'spython:3.13-slim-trixie), the pre-built React bundle, NSSM (service supervisor), and ffmpeg — everything Bambuddy needs to run end-to-end on a stock Windows 10/11 box. Architecture: install targetC:\Program Files\Bambuddy\, data targetC:\ProgramData\Bambuddy\data\(preserved on uninstall so reinstalls keep the database + archives), service registered via NSSM running asLocalSystemwith autostart on boot (LocalSystem is required because the Virtual Printer feature needs to bind 322 / 990 / 8883, all privileged ports on Windows). Browser is the UI — Start Menu shortcut openshttp://localhost:8000, no Tauri / Electron launcher in v1, which matches how every other Bambuddy platform already works. Why this shape over a PowerShellinstall.ps1: the script approach was tried first and abandoned. Each failure across the Windows host fleet is environmental drift (Python version mismatches, execution-policy variants, antivirus heuristics, missing MSVC runtimes, OneDrive-redirected%APPDATA%, ARM64 vs x64, PowerShell 5.1 vs 7.x semantics) — a script can't insulate against host state, and every fix you add for one machine breaks two others. The self-contained-bundle approach takes that whole class of failure off the table. Files:installers/windows/build.pystages everything underinstallers/windows/build/staging/,installers/windows/bambuddy.issis the Inno Setup 6 script,installers/windows/service/install-service.bat+uninstall-service.batwrap NSSM.build.pyhard-fails on non-Windows hosts; cross-build under Wine is an unsupported escape hatch behind--allow-non-windows. CI:.github/workflows/windows-installer.ymlruns on tag push (v*) and manual dispatch, useswindows-latest, downloads Inno Setup via Chocolatey, runsbuild.py+ ISCC, uploads the.exeas both a workflow artifact and a release asset. Scope clarification: this commit lands the build infrastructure, not a verified-working installer. The first real Windows-box smoke test happens after merge by triggering the workflow manually and installing the artifact on a target box; known unknowns are pip-installingopencv-python-headless/curl_cffi/asyncpg/cryptography/bcryptagainst embedded Python (the_pthfile edits inbuild.pycover the common gotchas but real-runtime imports are where surprises surface), ffmpeg path lookup from a LocalSystem service, and NSSMAppEnvironmentExtraline-continuation in cmd.exe. Signing: v1 ships unsigned — Windows SmartScreen will warn "Windows protected your PC" on first run, click-through works. SignPath OSS application submitted 2026-06-10 to wire free EV signing into CI once approved (typical 1–3 week approval window). What's explicitly NOT in v1: Spoolman bundling (Bambuddy's internal-inventory mode is the v1 default on Windows; users who want Spoolman install it separately), in-place upgrade (uninstall + install cycle works, but in-place upgrade-on-top needs end-to-end verification before we promise it), port-conflict pre-check (deferred to v1.1 — port collisions surface at first service start and the user reads the NSSM stderr log underC:\ProgramData\Bambuddy\logs\service-stderr.log). Seeinstallers/windows/README.mdfor the full build pipeline. -
VP wire-payload dump escape hatch for shape-of-payload triage (#1622 investigation) — When a virtual printer in non-proxy mode is misbehaving for the slicer-facing surface (AMS slot fields rendering empty, filament dropdown unselectable, K-profile not visible), the existing logs prove the bridge is bound and pushing at 1Hz but don't show what's actually in the wire payload. Without that, "cache is missing fields" is indistinguishable from "the slicer-facing copy is stripping them." Set
BAMBUDDY_VP_DUMP_WIRE=1and Bambuddy writes the bridge's cached push_status (<log_dir>/vp_wire/<vp_name>_in.json) and the periodic 1Hz copy that gets sent to the slicer (<log_dir>/vp_wire/<vp_name>_out.json) to disk, overwritten on each tick. Diffing the two answers the bisect question; diffing a misbehaving VP's_out.jsonagainst a known-good VP's_out.json(e.g. P1S vs H2D in the #1622 case) answers the model-shape question. Off by default, no overhead when disabled (single env-var read per tick); env var re-read on every call so toggling without restart works; failures swallowed at debug so a broken dump can never break the 1Hz loop. Implementation lives inbackend/app/services/virtual_printer/_debug.pywith call sites inmqtt_server.py::_send_status_report(cached branch only — synthetic fallback is uninteresting for this triage) andmqtt_bridge.py::_on_printer_raw(immediately after the merge that produces_latest_print_state). 21 unit tests intest_vp_wire_dump.pypin: disabled-by-default, atomic tmp+rename writes (no half-written .json visible to a reader), sanitized vp_name (path-separator stripped, empty name falls back tovp, .. inside a single filename component is harmless because slashes are collapsed before path construction), per-call env check, dict + bytes + str payload acceptance, swallow-on-OSError. Not gated on debug-logging because the bridge's verbose path is already noisy; this dump is small (one file per direction per VP) and only present when the operator opts in. Diagnostic-only — does not change the bridge data path. -
VP slicer↔printer command-flow trace (#1622 round 2) — The snapshot dump above answers "is the cached push shape correct?", but the round-1 captures from #1622 ruled that out: P1S AMS payload reaches the slicer byte-identical to what the printer sent, sticky-key preservation works, the visible slot data is intact. The remaining symptom (picking a generic filament in archive mode "unloads" the slot) lives on the command path, not in the periodic push — and the snapshot dump doesn't capture command traffic. Same env flag (
BAMBUDDY_VP_DUMP_WIRE=1) now also appends every slicer-originated publish ondevice/<vp_serial>/requestAND every printer-originated response the bridge fans out to the slicer (extrusion_cali_get, ams_filament_setting acks, xcam, system, etc.) to<log_dir>/vp_wire/<vp_name>_cmd.jsonl, one JSON line per event with UTC iso timestamp, direction (slicer_to_bridge/printer_to_slicer), MQTT topic, a<channel>.<command>grep handle, and the parsed payload. Excludes the cached-as-base 1Hz push (already covered by the snapshot dump) andpushall/get_version(handled locally, never forwarded). Printer-side captures happen AFTER serial rewrite so the dump matches what the slicer actually saw on the wire. Newappend_eventhelper in_debug.pymirrors the same swallow-on-OSError + sanitized-vp_name + per-call env-check posture asdump_wire; bytes payloads are utf-8 decoded then json-parsed with the same\x00-tolerance fix from #927 so OrcaSlicer's C-string-null publishes parse cleanly; un-parseable bytes fall back to{"raw": "..."}so every line stays valid JSON. Eight additional unit tests intest_vp_wire_dump.pypin: disabled-by-default, bytes parsing, trailing-null tolerance, unparseable-fallback, vp_name sanitization, iso timestamp shape, append-multiple-lines, swallow-on-OSError. Diagnostic-only — does not change the publish or fan-out data path. -
VP bridge-synthesised reply trace (#1622 round 3) — The round-2 cmd.jsonl from shaddowlink's P1S vs H2D capture proves the actual failure mode: on P1S in archive mode the slicer issues
extrusion_cali_set(push K/n directly) and the printer respondsfail, on H2D and on the P1S second round the slicer takes theextrusion_cali_selflow (select byfilament_id/cali_idx) and the printer respondssuccess. Both flows traverse the bridge cleanly —ams_filament_settinground-trips withresult=successand the cached push_status carriestray_info_idx=GFA11,tray_type=PLA-AERO, K/n, andcali_idx=-1intact. So the bridge is innocent on every layer the dump can see, and the open question becomes: what makes the slicer pick_setvs_sel? Likely candidates are theinfo.get_versionanswer Bambuddy synthesises (slicer fingerprints onsw_ver/hw_ver/moduleto decide its command flow) or the first cachedpushallresponse the slicer reads to bootstrap its UI. Round 2 captured neither — the JSONL hadslicer_to_bridgeandprinter_to_slicerdirections but nobridge_to_slicerdirection for the bridge's own synthesised replies. Same env flag (BAMBUDDY_VP_DUMP_WIRE=1) now also appends every bridge-synthesised reply (info.get_version answer, project_file ack, on-demand pushall response) to<log_dir>/vp_wire/<vp_name>_cmd.jsonlunder directionbridge_to_slicer. Capture lives inmqtt_server.py::_publish_to_report— the single chokepoint every synthesised reply already passes through — gated on a newlog_event: bool = Trueparameter; the 1Hz periodic-push path threadslog_event=Falseso the JSONL isn't flooded with ~60 lines/min per VP (snapshot dump already covers cache shape). The on-demand pushall response from_send_status_reportIS logged because that's the bootstrap-fingerprint reply the slicer reads on first connect. Two additional unit tests intest_vp_mqtt_bridge.py::TestWireFormatpin the event-on-default and skip-when-log_event=Falseposture;test_vp_wire_dump.pyalready covers the underlyingappend_eventshape and the new direction is documented in_debug.py's docstring. Diagnostic-only — does not change the publish data path; the new param defaults preserve every existing call site's behaviour. -
Batch grouping for queued items — multi-plate prints from one source 3MF now auto-group into a single collapsible row with aggregate stats, and a new "Group as batch…" action turns any 2+ selected items into a manual batch. Per-batch collapse state persists across reloads. Manual batches can be disbanded via the Ungroup action on the batch parent.
-
History batch grouping — siblings of the same batch collapse into one history row with status-rollup chips (e.g. 3 ✓ / 1 ✗) and the latest activity timestamp.
-
History thumbnail hover preview — hover any small history thumbnail and a 192×192 preview pops out next to it.
Changed
-
Queue page restructured around three tabs — Queue, History, and Timeline now live as separate tabs at the top. History no longer competes with the active queue for screen space.
-
Active queue layout toggle — pick between a flat list (current default) and a per-printer view where each printer becomes a section card with aggregate item count, total time, and total filament weight in its header.
-
Multi-drag reorder — selecting N items and dragging any one of them moves the whole selection as a contiguous block; the drag ghost shows a "+N" badge.
-
History rows redesigned — each row now carries a filament color swatch + weight + type, the user who started the print, and the failure reason inline on failed/skipped rows. Rows lay out in a responsive 1/2/3 column grid so a long history uses available horizontal space.
-
Timeline tab rebuilt as a Gantt swimlane — one horizontal row per printer (plus per target_model and unassigned), jobs rendered as bars positioned by start time and sized by duration. Live NOW marker, 24-hour rolling window with 12-hour step controls. Only committed schedules are shown — staged items, waiting items, and ASAP jobs on idle printers are hidden so the timeline reads as a real forecast.
Fixed
-
Virtual Printer queue mode: multi-plate "Send All" now enqueues one queue item per plate — BambuStudio / OrcaSlicer's "Send All" packs every plate of the project into a SINGLE 3MF and uploads it with one FTP STOR —
slice_info.configinside the file carries N<plate>blocks (one per plate), each with its own<metadata key="index" value="N"/>and its ownMetadata/plate_N.gcodepayload. Previously the VP queue path only ever extracted the FIRST plate's index via_extract_plate_idand created exactly ONE PrintQueueItem with that singleplate_id; plates 2..N silently dropped on the floor. Indistinguishable from the user's perspective from "Send" of a single plate — except they expected 3 items in the queue and got 1, with no log line to explain why. Confirmed against the wire on the live H2D-1 Proxy VP:Cube.gcode.3mfcarrying three<plate>blocks (indices 1, 2, 3) + three per-plate gcode payloads in the same zip, identical filename whether "Send" or "Send All" was clicked — the only signal of intent is the count of<plate>blocks inside the file. Fix: replaced_extract_plate_id(returningint | None) with_extract_plate_ids(returninglist[int]). The list contains every<plate>block'sindexmetadata, in order; falls back to[1]for files missingslice_info.configor with no parseable plates so the single-plate path is preserved._add_to_print_queuenow loops over the list — each iteration callsextract_filament_requirements(file_path, plate_id)per-plate (the plate-aware path was already there from the #1697 work) and creates a PrintQueueItem with that plate's filament types / overrides, plate-specific position =MAX(position) + iteration. Single-plate "Send" hits the loop once → exactly today's behaviour (one queue item, plate_id from the slicer, same archive). Multi-plate "Send All" of a 3-plate file → 3 queue items, plate_id 1/2/3, consecutive positions, all pointing at the same backing archive (one upload = one archive). What stays the same: the single archive row per upload (the archive backs the queue items viaarchive_id); theauto_dispatch=False/manual_start=trueposture inherited from the VP config (so multi-plate items still require manual start); thequeue_force_color_matchper-VP toggle (now applies per-plate). What this also fixed downstream: therequired_filament_types/filament_overridesJSON on each queue item now reflects THAT plate's filaments, not the file's first plate — so the scheduler's per-printer "Any X" matching dispatches each plate onto a printer with the right colours loaded for THAT plate, not for plate 1's filament set. Tests: 1 new regression case intest_virtual_printer.py::TestVirtualPrinterInstance::test_add_to_print_queue_multi_plate_send_all_enqueues_one_per_plate— builds a 3-plate 3MF (writes the per-plate<plate>blocks intoslice_info.configand the per-plate gcode payloads), runs_add_to_print_queue, asserts 3 PrintQueueItems withplate_id == [1, 2, 3],position == [1, 2, 3], sharedarchive_id, allmanual_start=True. 126 existing single-plate VP tests stay green (loop runs once when input has one plate). Full backend suite 5962/5962 green; ruff clean; frontend untouched. Live-verified on the H2D-1 Proxy VP — a Send All of the 3-plate Cube project now produces 3 queue items + 1 archive instead of 1 queue item + 1 archive. -
Archive delete now removes related queue items instead of leaving "cancelled" rows behind — Previously the soft-delete path (the default — what the trash-can button does) called
_cancel_pending_queue_items, which only flipped queue rows withstatus='pending'tostatus='cancelled'while leaving every other status alone AND leaving every row in the DB. The Send All multi-plate work above made this much more visible: deleting an archive backed by N queue items now had to clean up N rows, and what users saw instead was N "cancelled" rows lingering in the queue history. Fix (backend): replaced_cancel_pending_queue_itemswith_delete_related_queue_items(db, archive_id) -> intthat DELETEs every queue row wherearchive_id = Xregardless of status. Behavior now matches what the hard-delete path already did via theON DELETE CASCADEFK onprint_queue.archive_id— both paths produce the same end state. Print history lives inPrintLogEntry(FKON DELETE SET NULL) and is untouched, so stats / Quick Stats / accuracy bands are preserved across both delete paths. New guard: the route atarchives.py::delete_archivenow 409s when any related queue item is currently instatus='printing'— both soft and hard delete are gated by the same precondition, because deleting the archive while a print is live would strip the dispatcher's metadata trail (filament / plate / ams_mapping) out from under the running print. The 409 surfaces a clear "Stop the print first, then retry" message. Pre-flight count for the UI: new endpointGET /archives/{id}/delete-impactreturns{related_queue_items: N, currently_printing: M}— cheap, single endpoint, not folded into the archive list response so the much larger list endpoint isn't forced to run the same query per row. Frontend ArchivesPage delete-confirm modal queries this when the modal opens (useQuery({queryKey: ['archive', id, 'delete-impact'], enabled: showDeleteConfirm})) and renders: an amber warning "N queue item(s) linked to this archive will also be removed." when total > 0 AND printing = 0, OR a red warning "Cannot delete — M queue item(s) are currently printing. Stop the print first, then retry." when printing > 0 (with the confirm button disabled in that case so the user can't bonk the 409 on submit). ConfirmModal extension: added optionalconfirmDisabled?: booleanprop. ExistingisLoadingwas the only disable knob; this adds an external-precondition path that disables the confirm without the loading spinner. Tests: rewrotetest_print_queue_api.py::test_soft_delete_archive_cancels_pending_queue_items→test_soft_delete_archive_deletes_all_related_queue_itemsto pin the new contract (both pending AND completed rows are gone post-soft-delete). 2 new integration cases intest_archives_api.py:test_delete_archive_blocked_when_related_queue_item_printing(both soft and hard paths return 409 with "printing" in detail message) +test_archive_delete_impact_reports_counts(3 mixed-status related rows + 1 unrelated row → endpoint reportsrelated_queue_items=3, currently_printing=1, unrelated row doesn't bleed in). i18n: 2 new keys (archives.modal.deleteQueueItemsWarning,archives.modal.deleteBlockedByPrinting) translated across all 11 locales perfeedback_translate_dont_fallback— no English fallbacks. Verification: full backend suite 5964/5964 green with-n 30; ruff clean; ESLint clean;npm run buildclean; vitest 2118/2118 green; i18n parity 5109 × 11 locales green. No DB migration — the CASCADE FK was already in place; only the helper's semantics changed. -
Print Log table: multi-color filament rows render one swatch per color instead of a single barely-visible gray dot (#1731 part 1, reported by @IndividualGhost1905) — The per-archive Print Log table cell at
frontend/src/pages/ArchivesPage.tsx:3882rendered thefilament_colorcolumn as ONE swatch withstyle={{ backgroundColor: entry.filament_color.startsWith('#') ? entry.filament_color : undefined }}. For multi-color prints, the backend writesfilament_coloras a comma-joined string (e.g."#FFFFFF,#000000,#FF0000"— three filaments used in the print), which trivially passes the.startsWith('#')check but is not a valid CSS color. The browser silently dropped thebackgroundColordeclaration, leaving the swatch as only its black/20% border on the app's dark theme — visually a tiny grey dot, near-invisible against the row background, which the reporter's screenshots showed as "PLA" text in the cell with no apparent swatch at all. The DB column was correct (the reporter confirmed both colors were recorded for the old example); the render dropped them. The Archive Card view at:1072-1083and:2114-2125already split on comma and rendered one swatch per color — only the Print Log table cell had been missed when multi-color support was added across the rest of the page. Fix: the Print Log table cell now mirrors the card-view pattern — wraps the swatches in aflexcontainer, splitsentry.filament_coloron,, trims each value, and renders onew-3 h-3 rounded-fullper color withbackgroundColor: trimmed.startsWith('#') ? trimmed : undefinedand atitle={trimmed}for hover-tooltip parity. Single-color prints render exactly one swatch (the trivial case — no behaviour change). Empty / non-hex slot values gracefully fall through to nobackgroundColorrather than poisoning the CSS for adjacent slots. The filament-type text ({entry.filament_type || '—'}) keeps its existing position to the right of the swatches. What this does NOT fix: the reporter also flagged that new multi-color prints don't appear in the filament usage history. That's a separate code path (backend/app/services/usage_tracker.py::_track_from_3mfand the slot-to-tray mapping chain atusage_tracker.py:899-901), where the diagnostic needs the archive's capturedams_mapping, themappingfield from MQTT push_status at print start, and the[UsageTracker] PRINT START/PRINT COMPLETElog lines — none of which are in the reporter's first bundle. Tracking under #1731 part 2, blocked on a support bundle from the affected install. Tests: existingArchivesPage.test.tsx(23 cases) green; ESLint clean;npm run buildclean; i18n parity 5107 leaves × 11 locales green (no new keys). Frontend-only change. -
Finish-photo force-on removed; user's explicit timelapse=off in the slicer send dialog is now respected (#1721, reported by @agrisci) — On H2D 01.x firmware,
capture_finish_photo(default-on global setting) was forcing every print'stimelapseMQTT field toenableregardless of whether the user had unchecked the Timelapse box in OrcaSlicer's send dialog. That bit flips the printer's runtimetimelapse_record_flag, which un-gates the slicer-bakedM1002 judge_flag timelapse_record_flag/M622 J1/G1 X-48.2 F3000/M971 S11 C11 O0wipe blocks emitted by Smooth-mode timelapse profiles — so the toolhead parked off the part and snapped a frame every single layer, on prints the user explicitly opted out of recording. The reporter's gcode export confirmed the macro block was baked in (28 occurrences across the file) and the printer's MQTT log showedSending print command: {"print": { … "timelapse": true, … }}even though the slicer-side checkbox was unchecked. Live-stop confirmed: turning the globalcapture_finish_photosetting off in Bambuddy made the per-layer parking stop immediately. Root cause: the #1397 "finish photo from timelapse" feature used "force the printer into timelapse-recording mode at dispatch" as the side-channel to get a well-framed end-of-print shot (toolhead parked, before bed drop, extracted from the recorded video's last frame). That mechanism conflated two semantically different things — recording a timelapse video vs. snapping a finish photo — and the per-layer side effects of the recording mode were decided at slice time by the user'stimelapse_typeprofile setting, which Bambuddy has no visibility into post-slice. Traditional-mode gcode has no per-layer wipe block (no parking, no defects) — so the bug was invisible to anyone whose slicer profile defaults to Traditional. Smooth-mode gcode (the reporter's case) bakes the wipe block and gates it ontimelapse_record_flag, so flipping the runtime flag fired the macro every layer. Fix: replaced the force-on mechanism entirely with a clean MQTT-state-driven trigger.bambu_mqtt.py::_handle_push_statusnow fires a newon_finish_photo_momentcallback whenstg_curtransitions INTO 22 ("Filament unloading") while_was_running == TrueAND the end-of-print gate matches (progress >= 99ORlayer_num >= total_layersORremaining_time <= 0) — that's the same framing window #1397 was after (toolhead parked, bed not yet dropped, AMS pulling filament back) but reached via a clean state signal instead of by exploiting the per-layer macros. The end-of-print gate is what disambiguates from mid-print filament swaps in multi-color prints, which ALSO transit through stage 22 (M620 unload → 22, M621 load → 24) but always at progress < 99 / layer < total / remaining > 0. A FINISH-state fallback in the same handler fires the same callback at the existing FINISH-state transition if stage 22 never arrived — covers cancel-mid-print (state goes RUNNING → IDLE / FAILED without 22), external-spool-only prints where some firmwares skip the unload phase, HMS halts before unload, and any firmware variant we don't see stage 22 on. Net behavior: every print that gets a finish photo today still gets one; the lucky majority get the better-framed pre-bed-drop shot too.main.py::on_finish_photo_momentis a new top-level handler that pre-captures one camera frame at the trigger edge — external camera (snapshot URL → MJPEG fallback), buffered live RTSP frame from_active_streams/_active_chamber_streams, or a fresh RTSP grab viacapture_camera_frame_bytes— and caches the JPEG bytes in a module-level_stage22_finish_frames: dict[int, bytes]keyed by printer_id._background_finish_photo(insideon_print_complete) consumes the cached bytes via_stage22_finish_frames.pop(printer_id, None)before falling through to its existing live-grab chain, so the saved photo has the better framing without the existing complex archive-resolution / fallback / notification wiring needing to move. When a timelapse IS actively recording (user explicitly opted in this time), the pre-capture is skipped —_capture_finish_photo_from_timelapsestill extracts the last frame from the recorded video, which is still the highest-quality option and now has no force-on side effects because the user actually wanted the video. What was removed:resolve_effective_timelapseinbackground_dispatch.py(the shared force-on resolver),BackgroundDispatchService._resolve_effective_timelapsewrapper, both call sites inbackground_dispatch.py(_run_reprint_archive+ library-file print path), theresolve_effective_timelapsecall inprint_scheduler.py::_dispatch_item, thearchive.bambuddy_forced_timelapsewrite in the resolver, theif archive.bambuddy_forced_timelapse: await _cleanup_forced_timelapse(...)branch in_background_finish_photo, and the entire_cleanup_forced_timelapsefunction (~75 lines including the FTP-DELE walk across/timelapse//timelapse/video//record//recording). All call sites now readbool(item.timelapse)/bool(job.options.get("timelapse", False))directly — the literal user choice flows straight through tostart_print(timelapse=…). Thearchive.bambuddy_forced_timelapseDB column stays defined (defaultFalse) for back-compat with existing rows that may have it set toTruefrom before — no consumer reads it anymore, and dropping a column on the user-data table risks breaking restore-from-backup flows we don't need to break. New callback wiring: addedon_finish_photo_momentparameter toBambuMQTT.__init__, new_finish_photo_capturedone-shot flag (reset on each new print at the same site as_completion_triggered), newPrinterManager._on_finish_photo_momentfield +set_finish_photo_moment_callbacksetter, newon_finish_photo_momentinner wrapper in_setup_callbacks, threaded through to theBambuMQTTClientconstructor call.main.py::on_print_startclears any leftover_stage22_finish_framesentry from a prior print so a never-consumed cache (e.g. capture succeeded but on_print_complete bailed before reaching it) can't bleed into the new print's photo. Tests removed:test_cleanup_forced_timelapse.py(~290 lines, 7 test cases pinning the FTP-DELE walk andbambuddy_forced_timelapseflag handling),test_scheduler_force_timelapse_wiring.py(the source-pattern check that pinnedprint_scheduler.pyimportsresolve_effective_timelapse),test_dispatch_force_timelapse.py(5 test cases pinning the_resolve_effective_timelapsewrapper's interaction withcapture_finish_photo+ archive flag). The behaviour these tests verified is intentionally gone. Tests updated:test_background_dispatch_watchdog.pydropped twopatch.object(BackgroundDispatchService, "_resolve_effective_timelapse", ...)blocks that stubbed the now-removed method;test_background_dispatch.py::test_dispatch_options_pass_through_patterncomment updated to explain whytimelapsestays excluded from the bare-pattern needle check (the wrap inbool(...)is intentional to coerce non-bool option payloads, not a force-on remnant). Verification: ruff clean; full backend suite 5961/5961 green with-n 30; ESLint clean;npm run buildclean; vitest 2118/2118 green; i18n parity 5107 leaves × 11 locales green (no new keys). No migration. What this does NOT change: users who explicitly enable the Timelapse checkbox in the slicer send dialog still get the timelapse video AND the timelapse-extracted finish photo (highest-quality framing, no per-layer parking because that was never the issue — it's the user's intentional choice). Users who explicitly disable the Timelapse checkbox now get no per-layer parking AND still get a finish photo (pre-captured at the stage-22 edge for the same pre-bed-drop framing). -
Configure AMS Slot: filament profiles for other printer models now filtered out (#1623, reported by @shaddowlink) — Three independent gaps in the same picker, each surfaced by a different round of reporter screenshots. (1) Local "Custom" imported profiles were unconditionally listed regardless of the slot's printer; a user with PETG / PLA profiles imported from OrcaSlicer / BambuStudio for A1 mini, H2D, and P1S saw all three lined up when configuring an AMS slot on any one of those printers. (2) Cloud presets using the
@Bambu Lab <long-name>suffix form (user-renamed Bambu Cloud presets and most Orca Cloud profiles) slipped through the existing filter, which only matched the@BBL <short-code>form Bambu's system presets use. (3) Cloud presets with the printer model in the BODY of the name (the literal failure shape the reporter screenshotted on H2D:"X1C eSUN PETG-Basic Filament"with no@suffix at all) — the existing extractor returned null for these and the filter no-op'd. Fix:ConfigureAmsSlotModal.tsxnow (a) queries the backend's Bambu printer-model registry (/slicer/printer-models, same fetch SliceModal uses), (b) for local presets — reverse-looks-up the slot's short model code to a long printer-preset fragment, pairs it with the slot's nozzle diameter to synthesise the full slicer preset name ("Bambu Lab P1S 0.4 nozzle"), and passes that intopresetCompatibility(...)fromutils/slicerPrinterMatch.tsagainst each local preset's parsedcompatible_printersJSON; (c) for cloud / Orca Cloud presets —extractPresetModel(name, registry)is now multi-strategy: first the@BBL <code>form (existing), then the@Bambu Lab <long-name>form with case-insensitive reverse-lookup against the registry (so "A1 mini" vs "A1 Mini" capitalisation drift doesn't hide A1 Mini profiles, preserving the #1649 alias-aware match), then a body-text scan against every known model token (long-name fragments and short codes from the registry, long-first sort so "A1 Mini" / "X1 Carbon" / "H2D Pro" aren't eaten by their shorter siblings, word-boundary regex so "PA1" doesn't match "A1" and "X1Box" doesn't match "X1"). Presets where no strategy resolves still pass through — free-form names with no recognisable model token stay visible (can't filter what we can't classify). Fail-open posture preserved:matchandunknownverdicts keep showing for local presets (back-compat for hand-edited imports withoutcompatible_printers); the currently-configured preset (slotInfo.savedPresetId) bypasses the filter so the active selection always remains visible; built-in filaments stay unfiltered (generic fallback); when the registry hasn't loaded yet ORprinterModelis empty, every filter no-ops. No backend / schema / i18n changes. Frontend ESLint clean;npm run buildclean; vitestConfigureAmsSlotModal24/24 green. -
Virtual Printer: empty AMS slots forwarded as phantom loaded filaments to BambuStudio Sync (#1726, reported with full code-level analysis by @needo37) — On any VP bound to a target printer (Proxy mode, or Queue mode with a specific target), the slicer-facing AMS state was the printer's raw push_status — the empty-slot cleanup that
bambu_mqtt.py::_handle_ams_dataapplies to Bambuddy's own internal state was NEVER run on the bridge cache. Concrete case: real printer has 3 filaments loaded (AMS-A slots 2/3/4), AMS-A slot 1 and all of AMS-B empty; Bambuddy's AMS card renders the empty slots correctly as Empty (control — internal state path is fine), but BambuStudio after Sync paints 7 populated/green-checked filament slots — the 3 real ones plus 4 phantoms whose color/material is stale RFID/calibration data from before those slots went empty. The diagnostic signature is the mismatch between the AMS card (correct) and the slicer view (wrong) for the same payload. Archive mode and Queue-by-model are NOT affected — no target printer → no bridge → the slicer gets the synthetic stub atmqtt_server.py:927with no real AMS data. Root cause: two code paths consume the same printer AMS payload. Internal (bambu_mqtt.py::_handle_ams_datalines 1802-1858) parsestray_exist_bits, promotes empty slots tostate=9, and wipes the staletray_type/tray_color/tray_info_idx/tag_uid/tray_uuid/remainfields. VP bridge (mqtt_bridge.py::_on_printer_rawlines 551-656) deep-merges AMS structurally via_merge_ams_dictand copiestray_exist_bitsthrough as an opaque top-level scalar — but never applies the bit→clear-empty-slot logic. The cached state ships to the slicer untouched. Fix: factored the bit-clear logic out of_handle_ams_datainto a shared module-level helperbambu_mqtt.py::apply_tray_exist_bits(units, tray_exist_bits_str, *, power_on_flag, log_label)and call it from both paths. The internal call site is replaced with a single helper invocation; the bridge calls it on the merged AMS dict after_merge_ams_dictruns, before the 1 Hz cached-as-base push picks the cache up. Shared shutdown guard preserved on both sides: all-zero bits +power_on_flag=Falseis the printer-off pattern (#765) and skips cleanup — a non-zero bits + power-off combo is valid idle-printer state (#1365 — X1C between prints) and still applies. AMS-HT units (id >= 128) skipped on both sides (separate addressing scheme). Tests: newTestApplyTrayExistBitsHelperclass intest_bambu_mqtt.py(10 cases pinning the helper contract directly — missing/unparseable bits → no-op, shutdown guard, nonzero+power-off X1C case, int-9 state, AMS-HT skip, string id handling, multi-AMS global bit math, state-promote-even-without-stale-data). 3 new bridge regression tests intest_vp_mqtt_bridge.py::TestPushStatusCache:test_tray_exist_bits_clears_empty_slots_in_slicer_cachereproduces the #1726 wire shape (slot 0 carries staletray_type/tray_color/tray_info_idx/tag_uid/tray_uuid/remain+tray_exist_bits="e"→ slot 0 must clear, slots 1-3 preserved),test_tray_exist_bits_shutdown_guard_preserves_cachepins the printer-off path won't propagate phantom empties on every reconnect,test_tray_exist_bits_skips_ams_ht_unitspins the HT addressing skip. Existing internal-state tests for the bit-clear logic (test_tray_exist_bits_clears_empty_slots,test_tray_exist_bits_promotes_empty_slot_to_state_9,test_tray_exist_bits_does_not_change_state_on_loaded_slots, …) continue to pass against the refactored internal path — same contract, same behavior, different implementation seam. One pre-existing bridge fixture (test_partial_ams_unit_update_preserves_other_units) had an inconsistenttray_exist_bits="3"for two AMS units (bit 0 set, bit 4 unset, but both unit 0 and unit 1 had slot 0 populated as loaded). The fix exposed the inconsistency — corrected to"11"(bits 0 + 4) to match what the real printer would send. Full backend suite 5955/5955 green; ruff clean; i18n parity 5107 leaves × 11 locales green (no new keys). Frontend untouched. Verification on a live system (per @needo37's analysis): setBAMBUDDY_VP_DUMP_WIRE=1, restart, Sync the slicer, inspect<log_dir>/vp_wire/<vp>_out.json. For any tray whose bit intray_exist_bitsis 0,tray_type/tray_colorshould now be empty. -
Windows:
/api/local-backup/status500 onZoneInfoNotFoundError: 'No time zone found with key UTC'(from a user's log on the Windows installer) — Reported via a Windows traceback against the new local-backup status endpoint. The stdlibzoneinfomodule reads the system IANA tz database on Linux/macOS, but Windows has none — and the embedded Python in our Windows installer doesn't carry thetzdataPyPI package either, so evenZoneInfo("UTC")raisesZoneInfoNotFoundError._local_zone()inservices/local_backup.pyonly caught that exception for theTZ-env branch; the empty-TZfallback and the unrecognised-TZfallback both unconditionally calledZoneInfo("UTC")and re-raised, bubbling out of the FastAPI handler as a 500. Fix (two parts): (1)_local_zone()is now resilient — return type widened fromZoneInfototzinfo, theUTCfallback is wrapped in its own try, and the last-resort fallback returns the stdlibdatetime.timezone.utc(which needs no IANA DB and satisfies everyastimezone/str()call site downstream —str(timezone.utc) == "UTC"matches the previous response shape). Restores function on existing Windows installs without re-bundling. (2)requirements.txtnow pinstzdata>=2024.1; sys_platform == "win32"so the next Windows installer build ships the IANA DB and any non-UTCTZvalue (e.g.Europe/Berlin) resolves correctly — the stdlib fallback can only ever give UTC. Linux/macOS unaffected: the platform marker keeps them on the system tz DB they already have. Tests: newtest_zoneinfo_completely_unavailable_falls_back_to_stdlib_utcintest_local_backup.pymonkeypatchesZoneInfoto always raiseZoneInfoNotFoundErrorand pins that_local_zone()returnsdatetime.timezone.utcrather than propagating. 31/31 local_backup tests green; ruff clean. -
Print-modal "off" toggles for
flow_caliandnozzle_offset_calinow actually suppress the calibration stage (live-tested on H2D 01.x) — The Re-print / Schedule modal toggles for Flow Calibration and Nozzle Offset Calibration accepted the user's "off" choice and flowed it correctly through to theproject_fileMQTT publish — Bambuddy sentextrude_cali_flag: 2andnozzle_offset_cali: 2per our reading of "1 = run, 2 = skip" inherited from the #1478 / #1682 work. Live test on an H2D running firmware 01.x: with both toggles off in Bambuddy's modal, the printer'sstgqueue (the pre-print stage list firmware publishes via push_status) still included stage 8 ("Calibrating dynamic flow") and stage 39 ("Nozzle offset calibration") — and physically ran them at print start. The2value did NOT suppress the stage despite our earlier "skip and reuse stored PA" reading. Root cause: the encoding for the "off" wire value is0, not2. The2value appears to mean "skip the explicit calibration pass but still apply / verify the stored PA value via the calibration stage" — close to a no-op in terms of K-factor but the printer still queues the stage and runs the per-print physical sequence.0is what actually drops the stage from thestgqueue. A real BambuStudio Send-dialog capture on the same firmware (proxy-mode VP echo) also showed0for both fields when calibrations are unchecked, contradicting the #1478 commit message which read0as "never sent by BambuStudio." Fix:bambu_mqtt.py::start_print—extrude_cali_flagis now1 if flow_cali else 0(was2), andnozzle_offset_caliis1 if (nozzle_offset_cali and is_dual_nozzle) else 0(was2). The dual-nozzle gate stays — single-nozzle prints continue to force-skip the nozzle-offset calibration their head doesn't support (#1682).1(run) is unchanged on both fields. Verification: live re-test on the same H2D with both toggles still off —stg: [29, 13, 4, 14, 3](cooling, homing, filament change, nozzle cleaning, vibration comp). Stages 8 and 39 dropped out cleanly. What's NOT fixed:vibration_caliis a JSONfalsebool in both Bambuddy's and BambuStudio's wire format, and the H2D firmware queues stage 3 ("Vibration compensation") regardless of the bool value — this is firmware-side and not solvable at our dispatch layer with the current field. Captured as a follow-up to investigate whether a parallelvibration_cali_flaginteger field exists. Tests:test_bambu_mqtt.py—test_p2s_uses_boolean_formatflippedextrude_cali_flag == 2→== 0;test_nozzle_offset_cali_default_is_skip,test_nozzle_offset_cali_ignored_on_single_nozzle,test_nozzle_offset_cali_false_on_dual_nozzleflipped== 2→== 0; docstrings updated to reflect the #1721 finding. The1 if user_wantsbranch in both tests for the "on" case is unchanged. 281/281 bambu_mqtt tests green; full backend suite 5941/5941 green with-n 30; ruff clean; frontend untouched (rebuild + i18n parity confirmed clean perfeedback_run_all_ci_checks). -
Support-bundle log noise: VP bridge nudge + SD-card cleanup (#1721 adjacent, observed on reporter's A1) — Two warnings polluting every A1 support bundle on a healthy print. Neither was the cause of #1721's timelapse complaint — both are adjacent noise. (1)
request_status_update: not connected—mqtt_bridge.py::_resolve_clientcalls_request_version+request_status_updateimmediately after attaching a raw-message handler so the bridge cache populates without waiting for the next periodic pushall. The bind frequently races the real printer's MQTT TLS handshake — a slicer-side reconnect re-resolves the client before the underlying session has reconnected, especially on A1 firmware which reconnects more aggressively than X1/H2/P.request_status_updatelogs[serial] request_status_update: not connectedat WARNING on the not-connected return path. The nudge is a best-effort optimisation; the fall-through (next periodic pushall) populates the cache anyway, so the WARNING fires on routine, expected, recoverable state. Fix: gate both nudges oncurrent.state.connectedat the bind site. When the client comes up, the next_resolve_clienttick re-enters this branch on identity change OR the periodic pushall inbambu_mqtt.pyfills the cache — same end state, no benign WARNING. The WARNING inbambu_mqtt.py:3224is unchanged: it's still a real signal for the other callers (/printers/{id}/refresh-statususer API, bug-reporter helper) where "you asked for a refresh on a dead client" is genuinely worth logging. Newtest_post_bind_nudge_skipped_when_target_not_connectedintest_vp_mqtt_bridge.py::TestBridgeLifecyclepins the contract. (2)SD card cleanup failed after 3 attempts ... (file may linger on SD card)— The post-finish helper inmain.pydeletes the uploaded file from the printer's SD card to prevent the ghost-print-on-power-cycle behaviour (#374, #1542). It tries up to three candidate paths (derive_remote_filename(archive.filename), then{subtask_name}.3mf, then{subtask_name}.gcode), each up to 3 times with 2 s backoff, then logs WARNING if all fail.delete_file_asyncreturnedbool—Truefor success,Falsefor ANYTHING else (FTP 550 file-not-found, network error, auth fail, transient FTP error). The A1 firmware (and most other Bambu firmwares post-print) cleans the SD-card upload itself before our cleanup runs, every candidate FTP-DELE returns 550, all three retries × three candidates × 2 s sleeps fire, then WARNING. That WARNING shouldn't exist on a healthy print where the printer self-cleaned. The same shape exists in_cleanup_forced_timelapse(#1397) walking the four timelapse dirs. Fix:bambu_ftp.pynow exports aDeleteResultenum (DELETED/NOT_FOUND/FAILED).BambuFTPClient.delete_filedetects the 550 case viaisinstance(e, ftplib.error_perm) and str(e).startswith("550")(same pattern already used in the download path for the symmetricFileNotOnPrinterErrorsentinel from #972).delete_file_asyncnow returnsDeleteResult. Both post-finish cleanup helpers (main.py::on_print_finishedSD branch +_cleanup_forced_timelapse) only WARN when at least one candidate returnedFAILED; an all-NOT_FOUNDoutcome logs DEBUG ("nothing to delete — printer likely self-cleaned"). The cleanup helper also no longer burns the 2 s × 3 retry budget on aNOT_FOUNDresult (550 will never recover by waiting); onlyFAILEDtriggers backoff.DELETE /printers/{id}/files/...returns 404 (not 500) onNOT_FOUND, more accurate for the user-facing UI. Three other production callers (print_schedulerpre-upload delete, twobackground_dispatchfire-and-forget cleanups) are unchanged at the call site — they discard the return value. Tests:test_delete_fileandtest_delete_file_asyncintest_bambu_ftp.pyswitched to the enum (3 cases each). 2 new regression tests intest_cleanup_forced_timelapse.py:test_forced_no_warning_when_every_dir_returns_not_foundpins the #1721 path (every candidate dir → 550 → no WARNING, one DEBUG summary),test_forced_warns_when_any_dir_returns_failedpins the counterpart (any FAILED keeps the WARNING — that's the signal the maintainer wants).caplogasserts the log record's level + content directly. Full backend suite 5941/5941 green; ruff clean; frontend untouched (rebuild + i18n parity confirmed clean perfeedback_run_all_ci_checks). No migration, no new i18n keys, no schema changes. -
Virtual printer external spool (
vt_tray) went "invalid" right after a slicer filament pick (#1622 round 5, reported by @shaddowlink) — On a P1S in non-proxy VP mode, the reporter picked a filament for the external spool slot in BambuStudio's Device tab and the slot immediately rendered as invalid (color only, no profile, no K-profile, no nozzle temps), but recovered after a virtual-printer reload. AMS slot picks worked correctly. Wire dumps (BAMBUDDY_VP_DUMP_WIRE=1) captured the asymmetry: the bridge's outgoing 1 Hz cached-as-base push deliveredvt_tray = {tray_info_idx, tray_color}— 2 fields — where a real P1S sends ~20 (tray_type,state,remain,k,n,cali_idx,nozzle_temp_min/max,tray_uuid,xcam_info, ...). The same_out.jsonshowed AMS slots with the full 24-field dict because_merge_ams_dictdeep-merged them. Root cause: Bambu firmware sends a partialvt_trayincremental right after acknowledging anams_filament_settingforams_id=255(external spool) — carrying just the fields the slicer's pick changed. The round-4 per-field accumulate (#1622 /da799447) carried over prev keys NOT present in new, butvt_trayIS present in new, so the cached dict was REPLACED wholesale with the 2-field partial. The next 1 Hz cached-as-base push handed the slicer the stripped vt_tray; BambuStudio rendered the slot as invalid. Reloading the VP forced a reconnect → pushall → full vt_tray restored, and the cycle repeated on the next pick. Fix:mqtt_bridge.py::_on_printer_rawnow applies the same per-field accumulate one level deeper: for every top-level key whose prev AND new are both dicts, overlay new onto prev rather than replace.amsis explicitly excluded (already deep-merged by_merge_ams_dict). The same overlay protectsdevice,online,upgrade_state,ipcam,upload,netagainst future firmware partials with the same shape; thenet.infoIP rewrite path is unaffected because_rewrite_net_info_ipsruns againstnew_state["net"]before caching and the rewritten list overrides the cached one on overlay (onlynet.confand friends, when sent withoutinfo, draw from prev now). Tests: newtest_partial_vt_tray_update_overlays_onto_cached_full_dictregression case intest_vp_mqtt_bridge.py::TestPushStatusCacheconstructs the exact P1S wire shape — pushall with the full ~20-field vt_tray, followed by the{tray_info_idx, tray_color}partial that shaddowlink's dump captured — and assertstray_type,state,remain,k,n,cali_idx,nozzle_temp_min/max,tray_uuid,idall survive while the two incoming fields take their new values. All 53 bridge tests stay green; 287/287 across the broader VP test surface (mqtt_bridge / mqtt_server / vp_wire / virtual_printer); ruff clean. Bridge code path only; no migration, no new i18n keys, no frontend touch. -
Library G-code preview returned raw ZIP bytes as
text/plainfor sidecar-sliced rows (#1709, root cause + fix from @yanglei1980) —slice_and_persistwrites its output as a.gcode.3mf(a ZIP container with embedded G-code) but persisted the LibraryFile row withfile_type="gcode". The G-code preview endpoint atlibrary.py::get_gcodeshort-circuits onfile_type == "gcode"and streams the on-disk bytes withmedia_type="text/plain", so every preview of a sidecar-sliced row handed the embedded viewer the raw ZIP body (PK\x03\x04…) instead of toolpath text — the viewer rendered nothing. External-folder scans (#1600) already typed.gcode.3mfrows correctly and hit the unzip branch, so the bug was specific to the sidecar slice path. Plain.gcodeuploads were unaffected (their on-disk bytes really are text). Fix: (1) forward —slice_and_persistnow persistsfile_type="gcode.3mf", matching what_classify_file_typereturns for the.gcode.3mfextension and what external-scan rows already use; (2) back-compat —get_gcodealso routes to the unzip branch when the filename ends with.gcode.3mf, so rows already written under the bug self-heal on first preview without a DB migration. UI gates: three frontend call sites that gated badge colour or the preview-eye icon onfile_type == "gcode"were extended to also accept"gcode.3mf"—FileManagerPage.tsxbadge + viewer-affordance gate,ProjectDetailPage.tsxbadge — so the new typing doesn't regress visuals. The print / queue / slice action buttons use filename-based helpers (isSlicedFilename,isSliceableFilename) that already accept.gcode.3mf, so they need no change. Tests: newtest_library_get_gcode_recovers_legacy_gcode_type_for_3mfregression case intest_library_api.pyconstructs a row withfile_type="gcode"+.gcode.3mffilename pointing at a real ZIP, asserts the response istext/plain, containsG28, and does NOT start withPK— pins the legacy-row recovery path. Existingtest_library_get_gcode_endpoint_accepts_compound_file_typecontinues to cover the forward path. Full backend suite 5920/5920 green; ruff clean; frontend ESLint +npm run buildclean; FileManagerPage / ProjectDetailPage / FileManagerExternalFolder vitests 69/69 green; i18n parity unchanged (no new keys). PR #1709 closed for CONTRIBUTING.md non-compliance (branched from main, no issue, template incomplete); root cause + fix shape preserved here ondev. -
Cloud + Orca Cloud preset resolver: pin
typeandfromto CLI-accepted values (#1712 follow-up, reported by maziggy on the Mecha Mewtwo slice) — Removing bundle mode (entry above) routed every slot through the cross-tier preset resolver. Cloud-tier presets surfaced two latent shape mismatches that bundle dispatch had been masking by materialising preset JSONs from.bbscfg-on-disk. (1)typefield: Bambu Cloud labels presets withtype: "printer"/"print"/"filament", but the BambuStudio CLI's--load-settingsparser only accepts"machine"/"process"/"filament". The user's first failing slice producedoperator(): unknown config type print of file preset.json in load-settingswith exit code -5; the sidecar surfaces this as a generic "The input preset file is invalid and can not be parsed." (2)fromfield: Bambu Cloud's filament detail endpoint routinely ships presets with emptyfrom(or nofromat all). The CLI's compatibility check rejects either withoperator(): file ... 's from unsupported(the double space in stderr = empty value). Same -5 exit, same generic "input preset invalid" surface. The sidecar'snormalizeFromFieldalready maps"User"/"System"→"system", but it doesn't touch empty / missing values. Fix:_resolve_cloudand_resolve_orca_cloudnow forcetype = _SLOT_TO_PROFILE_TYPE[slot]andfrom = "system"on the payload beforejson.dumps, mirroring what_resolve_standardalready does for the standard-tier stub. Both fields are unconditionally rewritten — idempotent on already-correct payloads, and pinning to "system" is consistent with how Bambuddy presents these post-flatten presets to the CLI (no parent walk needed, the cloud detail comes back fully expanded). Tests: newtest_cloud_rewrites_type_field_for_cli(7 parametric cases covering all six type-name variants Bambu Cloud emits plus the missing-type case),test_cloud_pins_from_field_to_system(4 cases: empty, already-system, GUI User, GUI System), andtest_cloud_synthesises_from_field_when_missing(the actual Mecha Mewtwo failure shape) pin the resolver-level contract. Existing happy-path assertions for_resolve_cloud/_resolve_orca_cloudupdated to include the new fields. 28/28 preset-resolver tests green, full backend suite 5919/5919 green, ruff clean. What this can NOT recover: if Bambu Cloud later starts emitting afromvalue other than empty / "User" / "System" that genuinely means something (e.g. "project"), Bambuddy will silently flatten it to "system" too. We accept that trade-off because the alternative is leaving "input preset invalid" failures on every cloud slice, and "system" matches how the sidecar's own resolver normalises the post-flatten state. -
Virtual printer cache drained capability/lifecycle fields between pushalls, greying out Device-tab UIs (#1622 round 4, reported by @shaddowlink) — Reporter on a P1S in archive mode saw the AMS-slot filament dropdown empty and the "Manage calibration data" UI disabled in BambuStudio's Device tab, while the same panels worked correctly on his H2D. After three rounds of triage on the printer-side payload (which traced clean — bridge passes
vt_traybyte-identical,tray_info_idxresolves, AMS slots populate), the actual asymmetry surfaced in the bridge cache dumps: P1S cachedprintstate contained 17 top-level keys; H2D contained 99. The missing fields were exactly the capability/lifecycle gates BambuStudio reads to decide which Device-tab UIs to enable (cali_version,print_type,gcode_state,mc_print_stage,mc_stage,device,cfg,home_flag, themc_*family, fan speeds — ~80 fields). Root cause: Bambu firmware sends a full top-level field set in pushall responses (onpushallrequest / printer reconnect) and ~1 Hz incrementals carrying just what changed (typically temps, fan, wifi, status)._on_printer_rawinmqtt_bridge.pycached the latest push asnew_state = copy.deepcopy(print_data)— replacing the prior cache wholesale — then re-merged only a hand-picked allowlist (_SLICER_VISIBLE_STICKY_KEYS) of 14 keys back from prev. The allowlist covered the #1371 / #1387 / #1228 / #1558 failure modes but missed capability/lifecycle fields entirely, so every 1 Hz incremental drained ~80 fields out of the cache and the slicer's gated UIs flipped off as soon as the cache thinned. The code comment claimed the cache "mirrors the same preservation pattern Bambuddy uses for its own internal state in bambu_mqtt.py" but it didn't: internal state is updated per-field (if "X" in data: self.state.X = ...), never drops what it's seen, and accumulates monotonically. Fix: replace the allowlist-preserve with per-field accumulate. For every key in the prior cache, carry over verbatim when the incoming push omits it; let new values overwrite when present. The_merge_ams_dictdeep-merge for partialamsblobs stays (#1387 / #1371 regression guards still pass)._SLICER_VISIBLE_STICKY_KEYSis removed entirely — the new logic is a strict superset of every case the allowlist handled. Why most P1S users don't hit it: timing. The typical workflow is connect → BS issues pushall → cache fills → click Device tab within seconds → UI works. shaddowlink's sequence kept BS idle long enough between pushalls that the cache thinned to incremental-only state before he clicked. X1C users hit the same drain but don't notice — older BS capability spec doesn't gate the same UIs oncali_version/mc_print_stage. H2D escaped detection because his captures happened to land close to a pushall reply (cache still fat). Tests: newtest_incremental_push_preserves_non_allowlisted_capability_fieldsregression case intest_vp_mqtt_bridge.py::TestPushStatusCacheconstructs a full push withcali_version/print_type/gcode_state/mc_print_stage/mc_stage/device/cfg/home_flag, follows it with a temps-only incremental, and asserts every capability field survives. All 51 existing bridge cache tests stay green — same behaviour for the allowlist subset, plus the formerly-dropped fields. Bridge code path; no migration, no new i18n keys. -
Force-color-match checkbox missing when scheduling against a specific printer (#1717, reported by @SamNuttall) — The Print Queue's schedule dialog hides the per-slot "Force color match" checkbox in the "Specific printer" path. Picking "Any A1" (model-mode dispatch) renders
FilamentOverridewhich carries the checkbox, but picking a single printer rendersFilamentMappinginstead — a separate component that had no force-match UI. The dispatcher inprint_scheduler.py:535already honoursforce_color_matchregardless of how the queue item was created (the flag survives end-to-end on thefilament_overridespayloadbuildFilamentOverridesArrayconstructs inPrintModal/index.tsx:613), so this was a pure UI gap — printer-mode users could not request the safety guard from the modal even though the backend would have respected it. Fix:FilamentMappingaccepts new optionalforceColorMatch+onForceColorMatchChangeprops mirroringFilamentOverride's shape; it renders the same<Palette>-iconed checkbox under each filament row when a handler is provided.PrintModal/index.tsx:1100passes the existingforceColorMatchstate and asetForceColorMatchsetter through — same state object both modes write into, so toggling between modes preserves what the user selected. No new i18n keys (the existingprintModal.forceColorMatchkey already ships in all 11 locales). The checkbox is suppressed when no handler is wired (avoids dead UI in callers that don't manage the flag). Tests: newrenders the per-slot force-color-match checkbox in printer mode (#1717)case clicks the checkbox and assertsonForceColorMatchChange(slotId, true)fires; companionomits the force-color-match checkbox when no handler is providedcase pins the absent-handler branch. Existing FTS dropdown-filter tests stay green.FilamentMapping.test.tsx4/4 green; combined PrintModal + FilamentOverride + FilamentMapping suite 73/73 green; eslint clean; frontend build clean; i18n parity 5120 leaves × 11 locales green. -
In-app updater fails when DATA_DIR is on a separate mount from the install (#1715, reported by @francescocozzi) — Native installs that follow the systemd template
WorkingDirectory=/opt/bambuddywithEnvironment="DATA_DIR=/srv/bambuddy/data"(or any layout whereDATA_DIRis not a subdirectory of the install path) couldn't apply in-app updates. Every git step in_perform_update(remote get-url,remote set-url,fetch,reset --hard) usedcwd=settings.base_dir, andsafe.directorywas pointed atbase_dirtoo. On the standard install (DATA_DIR=INSTALL_PATH/data) this happened to work by accident — git walks up from a subdirectory of the repo to find.git— but on a separate-mount layout the data dir is not under the install, the walk-up has nowhere to go, and every operation returns "fatal: not a git repository." Even on the standard installsafe.directory={base_dir}was wrong (it must equal the repo root git discovers, not the data dir), surfacing on hardened systemd units as "fatal: detected dubious ownership." Fix: route every git subprocess in_perform_updateand_origin_points_at_repothroughcwd=settings.app_dir(the working tree), and setsafe.directory={app_dir}to match.app_diris now resolved once at the top of_perform_updateinstead of lazily re-resolved before the pip step. Thebase_dirparameter on_origin_points_at_repois renamed toapp_dirso the signature documents the contract. The pip-install step keepscwd=app_dir(unchanged — that step was already correct). Tests: newtest_perform_update_runs_git_in_app_dir_when_data_dir_on_separate_mountintegration case constructs a sibling-paths layout (tmp/opt/bambuddy+tmp/srv/bambuddy/data— the exact #1715 shape), mocksasyncio.create_subprocess_execto capture every call's cwd, and pins (a) every git subprocess runs withcwd=app_dir, (b) the embeddedsafe.directory=config equalsapp_diron every git call. The existing pip-cwd test stays green (pip's cwd was alreadyapp_dir). Existing SSH-origin-preserve + origin-rewrite + reset-target tests stay green (they don't assert on git cwd). Fulltest_updates_api.py21/21 green; ruff clean. Credit: root cause + fix shape from francescocozzi via PR #1716 (couldn't be merged as-is — that branch had drifted off an olderdevand pulled in unrelated upstream commits including a version regression). -
SliceModal preset-lookup precedence + cross-tier dedup + signed-out banner (#1712, reported by @IndividualGhost1905) — After the Orca Cloud integration shipped (2026-06-04), every user — including Bambu-Cloud-only / Bambu-Studio-preferred users — got Orca Cloud as the top tier across the SliceModal preset picker, the per-preset auto-pick scoring, the dropdown's optgroup rendering, the AMS slot picker's filament sort, and the backend dedup precedence. A Bambu-Cloud / X1C user reported seeing his Bambu Cloud profiles disappear from auto-pick because Orca Cloud's empty tier shadowed them. The cross-tier dedup (introduced with #1150 and inherited as-is by the Orca change) compounded the problem: a name that existed in multiple tiers showed in only ONE group, so a user with a local-imported and Orca-synced "Bambu PLA Basic" never saw the Orca copy as a picker option — even though they curate both sources. And the cloud-status banner (
CloudStatusBanner) nagged signed-out users with a permanent "Sign in to Orca Cloud (Profiles → Orca Cloud) to see your Orca presets" at the top of every SliceModal open — even after a user had explicitly logged out of Orca Cloud. The Bambu Cloud banner had the symmetric problem. Fix — order: precedence islocal > orca_cloud > cloud > standardacrossSliceModal.tsx(SLICE_MODAL_TIER_ORDER+TIER_BONUS+ dropdown tier list),ConfigureAmsSlotModal.tsx(sourceOrder), and docstrings inslicer_presets.py/schemas/slicer_presets.py/client.ts. Local imports win (the user did them on purpose), Orca Cloud comes next, Bambu Cloud, bundled fallback last. The order drives auto-pick + visual group order; it does NOT hide profiles. Fix — no dedup, full lists:_dedupe_by_nameis replaced by_enrich_cloud_metadata, which returns every tier's full preset list across all three slots (printer / process / filament) — a name in local AND orca_cloud AND cloud renders in EACH of their groups so the user can pick any source. The only work the function still does is filament-metadata backfill: a Bambu Cloud filament without its ownfilament_type/filament_colourinherits values from a same-named local / orca_cloud / standard entry so it can still score inpickFilamentForSlot. Printer + process presets carry their metadata inline and need no enrich. Frontend code already iterates tiers in priority order and surfaces every entry — no change needed there once the backend stops filtering. Fix — banner:CloudStatusBannernow silently returns null onnot_authenticatedin addition took— applies symmetrically to both Bambu and Orca cloud banners.expired(token broke) andunreachable(network / service down) still surface — those are real breakage states a previously-signed-in user needs to see. Sign-in lives on the Profiles page; the modal doesn't need to advertise it. Theslice.cloud.notAuthenticated/slice.orcaCloud.notAuthenticatedi18n keys stay in the locale files (dormant) so re-enabling later doesn't need a re-translation pass. Fix — ConfigureAmsSlotModal source badges: before this change, the per-row source badge fired three branches independently —localgot a green "Local" badge,builtingot an amber "Built-in" badge, and a blue "Custom" badge appeared on top of those whenisUserwas true. Since ALL Orca Cloud entries are markedisUser: trueand Bambu Cloud user presets also get the same flag, the result was visually inconsistent: Orca Cloud rows showed only "Custom" (no source identification, no way to tell them from Bambu Cloud user presets), Bambu Cloud built-in rows had NO badge at all, and the "Custom" badge collided with the actual source. Replaced with a single source badge per row: green "Local", purple "Orca Cloud" (new), bambu-blue "Bambu Cloud" (new), amber "Built-in". One badge per row; one colour per source; theisUserdistinction within the Bambu Cloud tier is dropped (the preset name itself carries the "is this user-authored" signal). Same change in both render blocks (the filament-list code is duplicated in the modal — kept the duplication local rather than refactoring out a helper component in this PR to keep the diff tight). i18n: 2 new keys (configureAmsSlot.orcaCloud,configureAmsSlot.bambuCloud) translated to all 11 locales — both are brand names, already on the per-localeIDENTICAL_TO_EN_ALLOWEDlists so the parity check is satisfied without per-locale variants. The dormantconfigureAmsSlot.customkey stays in the locale files. Tests:TestEnrichCloudMetadatareplacesTestDedupeByName(5 cases): regression guard pinning that a name in all four tiers appears in EACH (not just local), tier order preserved within a tier, Bambu Cloud filament metadata backfilled from local, backfill falls through to orca / standard when local doesn't carry the name, backfill does NOT overwrite Bambu Cloud's own metadata when present. The "renders a sign-in banner when cloud_status is not_authenticated" case flipped to assert no banner appears, with the test name updated to call out the #1712 reason. Backendtest_slicer_presets.py47/47 green;SliceModal.test.tsx34/34 green;ConfigureAmsSlotModal.test.tsx24/24 green; ruff clean; frontend build clean; i18n parity 5120 leaves × 11 locales green. -
Telegram (and other image-bearing) finish notification on a reprint-from-archive showed the original print's finish photo instead of the new run's (#1707, reported by @kycrna) — P2S user reprinted an archived job and observed the Telegram notification arriving with the photo of the original print (white box) attached to the completion message for the new run (black box). Root cause: reprints reuse the source archive row —
register_expected_printstores the sourcearchive_idin_expected_prints, and the on-print-start expected-archive promotion branch atmain.py:2207-2245updates the row's status / started_at / printer_id / subtask_id but never resetarchive.timelapse_path. Two failure modes cascaded from the stale path: (a)_scan_for_timelapse_with_retriesearly-returns atmain.py:3062withif archive.timelapse_path: return— the reprint's new timelapse MP4 sitting on the printer's SD card was never downloaded, the archive'stimelapse_pathkept pointing at the original run's local file; (b)_capture_finish_photo_from_timelapsepollsarchive.timelapse_pathand immediately found the original video, extracted ITS last frame asfinish_<fresh-timestamp>_<uuid>.jpg, and handed those bytes to_background_notificationsasimage_data— which then went out to Telegram via thesendPhotopath. The filename was new (so log lines and the archive'sphotoslist looked correct), but the pixels were the original run's finish frame. Surface was specific to the timelapse-prefer path: withdata.timelapse_was_activetrue and no external camera,prefer_timelapse_sourcewas True, which is the exact configuration on P2S with timelapse-on for both runs. External-camera, buffered-frame, and fresh-RTSP fallback paths grab the current camera frame, so users on those paths saw correct photos and the bug stayed hidden. Fix: at expected-archive promotion, capture and cleararchive.timelapse_pathto None before the commit, andos.unlinkthe stale on-disk video so reprints don't accumulate orphaned MP4s in the archive directory. Photos list is left alone — accumulating one finish photo per run across the archive's lifetime is the right behaviour. The unlink is wrapped inOSError-catching best-effort logging so a missing file (manual delete, archive purge, container rebuild with bind-mount drift) doesn't break promotion. The clear-and-unlink runs unconditionally whentimelapse_pathis set, so even if a user has been reprinting under the buggy build for months, the next reprint self-heals. Tests: 3 new cases intest_reprint_clears_stale_timelapse.pyexercise the fullon_print_startcallback through the expected-archive branch — happy path (path cleared + file unlinked), no-prior-timelapse (no-op, promotion still succeeds), missing-stale-file (best-effort unlink doesn't raise). Fulltest_print_start_expected_promotion.py+test_print_start_assigns_printer_id_to_vp_archive.pysuite (28/28) stays green; ruff clean. -
Connection diagnostic no longer flags
external_storage: failon A1 / A1 Mini, which physically have no MicroSD slot (#1703, reported by @MartinNYHC) — Bug report from an A1 user complained that BambuStudio and OrcaSlicer don't have an "external storage" tick box (correct — there's nothing to toggle, the A1 series ships without a SD card slot at all) while the Bambuddy support bundle simultaneously reportedexternal_storage: failin the printer's connection diagnostic. The two together left the user thinking Bambuddy was wrong about a setting their hardware doesn't have. Root cause: theexternal_storagecheck atservices/printer_diagnostic.py:179-189readsstate.store_to_sdcard, which is parsed from MQTThome_flagbit 11. On A1 and A1 Mini that bit is never set (no hardware slot, no firmware-side toggle, no slicer-side equivalent), so the value pushes asFalseand the check fell through tofailinstead ofskip. Fix: newNO_EXTERNAL_STORAGE_MODELSfrozenset inutils/printer_models.pyenumerating A1, A1 Mini, and their internal codes (N1, N2S, A04, A11, A12), plus ahas_external_storage(model)helper that returns False for those and True for everything else (unknown models default to True so the check stays active for any future Bambu model that ships with a slot — new no-slot models must be added to the set explicitly). The diagnostic now short-circuits toskipbefore readingstore_to_sdcardwhenprinter.modelis in the set. What this does NOT change: X1 / X1E / P1S / P1P / P2S / H2D / H2D Pro / H2C / H2S / X2D continue to evaluatestore_to_sdcardexactly as before — the home-flag-bit-off →failpath is still the right signal for them. The companion FTP-upload-timeout symptom in the same bug report (ftp code 28 from BambuStudio when sending to the proxy VP) is a separate Docker-bridge-mode networking constraint, not addressed by this change. Tests: 8 new cases —TestHasExternalStorage(5 cases) pins the model list, internal-code aliasing, case/whitespace normalisation, unknown-defaults-true, and null/empty-defaults-true;TestExternalStorageCheckgainstest_skips_on_a1_no_external_storage_slot,test_skips_on_a1_mini_no_external_storage_slot, andtest_still_fails_on_x1c_when_toggle_off(regression guard that the model-aware skip doesn't accidentally silence the genuine signal on slotted models). Fulltest_printer_models.py+test_printer_diagnostic.py+ archives integration suite green (172/172); ruff clean. -
AMS slot card surfaced the previous spool's preset name after RFID auto-assigned a new spool (reported with H2D-1 / AMS-B3 / PLA-CF showing as "Bambu PLA Silk+") — Reporter inserted a fresh Bambu PLA-CF spool into AMS-B3, RFID identified it correctly, but the slot card kept showing "Bambu PLA Silk+" (the name from a PLA Silk+ spool that had occupied the slot back in March). Confirmed in the live data:
slot_preset_mappingsrow for(printer_id=1, ams_id=1, tray_id=2)waspreset_id=GFSA06_09, preset_name='Bambu PLA Silk+', updated_at=2026-03-15— three months stale. Root cause:slot_preset_mappings.preset_nameis first in the PrintersPage display chain (PrintersPage.tsx:3624) and overrides the spool's ownslicer_filament_nameplus the cloud catalogcloudInfo.name. The internal-mode manual-assign path (inventory.apply_spool_to_slot_via_mqtt) kept this row in sync, but the internal-mode RFID auto-assign path (spool_tag_matcher.auto_assign_spool) skipped it entirely. The Spoolman-mode sync path (main.auto_sync_spoolman_ams_trays) also skipped it — same bug shape, latent for Spoolman users who'd never manually configured a slot preset, active for those who had. Fix — three writers in lockstep via one shared helper. Newbackend/app/services/slot_preset_writer.pyexposes a primitiveupsert_slot_presetplus two convenience wrappers:upsert_slot_preset_for_spoolfor internalSpoolORM objects (local-preset numeric ids →local_{n}, cloud ids run throughfilament_id_to_setting_id) andupsert_slot_preset_for_spoolman_spoolfor Spoolman dicts (filament.name → preset_name, tray_info_idx → preset_id). All three call sites — the manual-assign block ininventory.py:396-438, the RFID auto-assign tail inspool_tag_matcher.py:auto_assign_spool, and the per-tray-sync branch inmain.py:auto_sync_spoolman_ams_trays— now go through the helper. Self-heal: existing stale rows from past spool swaps get rewritten the next time a fresh spool is detected on the same slot. No migration script needed. What this also covers perfeedback_inventory_modes_parity: the bug shape exists in both internal and Spoolman modes, so the patch ships fixes for both inventory paths in the same drop — a Spoolman user with a manually-configured slot preset would have seen the same stale-name behavior after every RFID swap until the row was overwritten through Configure Slot. Tests: newtest_slot_preset_writer.py(6 cases) pins the helper contracts — no-op on empty preset_id, upsert idempotency, Spoolman filament.name → preset_name, fallback to material → tray_sub_brands → tray_type, stale-row overwrite from the Spoolman path, skip when tray_info_idx is unknown. Newtest_spool_tag_matcher.pycases (3) pin the internal RFID-auto-assign path — stale-row overwrite (the exact reporter shape: PLA Silk+ → PLA-CF), fresh insert when no row exists,local_{n}formatting for numeric local-preset ids. Total touched-area suite 69/69 green; broader related suite (inventory + spoolman + spool_tag + auto_sync) 767/767 green; ruff clean. -
Stats page Failure Analysis widget rendered raw camelCase keys instead of translated reasons (#1687 follow-up, reported by @IndividualGhost1905) — After #1687 part 4 shipped the per-row Print Log editor, the reporter classified a couple of failed runs and saw "filamentRunout" / "cloggedNozzle" (the literal camelCase keys) appear under Statistics → Failure Analysis → Top Failure Reasons, while the same rows rendered correctly as "Filament runout" / "Clogged nozzle" on the Print Log table. Surfaced an inconsistency I introduced when shipping the new editor: the new Print Log row editor saves the camelCase key (
filamentRunout) which is what the new backend PATCH validates against, but the olderEditArchiveModalwas still saving the localised label ("Filament runout") as the value — two formats landing in the samePrintLogEntry.failure_reasoncolumn from two different UI surfaces. The Failure Analysis widget atfrontend/src/pages/StatsPage.tsx:817and the per-archive run history sub-table atfrontend/src/components/PrintLogTable.tsx:81both rendered the raw column value without running it through i18n, so the new key-form values surfaced as literal keys. Fix — three sites in one drop: (1)StatsPage.tsxand (2)PrintLogTable.tsxnow wrap the value int('editArchive.failureReasons.${reason}', { defaultValue: reason })— same pattern already used atArchivesPage.tsx:3874for the Print Log table. ThedefaultValuefallback keeps legacy translated-text rows rendering as-is, no regression. (3)EditArchiveModal.tsxnow saves the camelCase key (<option value={reasonKey}>) instead of the localised label, matching the new editor's wire format. On modal open, a reverse-lookup against the current locale resolves any legacy translated-text value back to its key so the dropdown pre-selects the right option — every save thereafter converts that row forward to the key format, so the data set self-heals over time without a migration script. AddedhtmlFor/idlinkage to the failure-reason<label>/<select>pair as a side benefit (letsgetByLabelTextin tests reach the control, plus a small a11y improvement). What this also fixes invisibly: German / Japanese / Turkish users who classified rows under one UI language and then switched languages would have seen their historical buckets fragment in the Failure Analysis widget (each translation = its own group). With keys as the storage format, language switch no longer reclassifies anything. Tests: 5 new vitest cases — StatsPagetranslates camelCase failure-reason keysandrenders legacy translated-text failure reasons unchanged; EditArchiveModalpreselects the option when the stored value is already a camelCase key,reverse-looks-up a legacy translated value back to its key, andsends the camelCase key on save, not the translated label; PrintLogModaltranslates camelCase failure_reason keys. The existingshows failure_reason under failed runscase (which checks legacy text path) keeps passing under the defaultValue fallback. Full vitest 58 / 58 across touched files. ESLint clean; frontend build clean (vite 9.61s); i18n parity 5118 leaves × 11 locales green (no new keys — reuseseditArchive.failureReasons.*). -
System page boot time was rendered with a doubled timezone offset (#1690 follow-up, reported by @IndividualGhost1905) — After the original #1690 fix landed in 0.2.4.6, the reporter on UTC+3 (Turkey) confirmed uptime was correct but boot time displayed +3 hours ahead of reality. Root cause:
backend/app/api/routes/system.pybuiltboot_timeas a NAIVE LOCAL datetime viadatetime.fromtimestamp(psutil.Process(1).create_time())and serialised it with.isoformat(), which emits no timezone marker (e.g."2026-06-09T11:22:05"). The frontend'sparseUTCDate()helper atfrontend/src/utils/date.ts:206is documented to append'Z'when no tz marker is present, treating the string as UTC, thentoLocaleStringconverts UTC → local — applying the local offset on top of an already-local timestamp. Uptime was unaffected because it's computed entirely backend-side asdatetime.now() - boot_time, two naive-local values whose delta is correct regardless of the missing tz info. Fix: make both boot_time and the uptime anchor tz-aware UTC —datetime.fromtimestamp(ts, tz=timezone.utc)on the main path and thepsutil.boot_time()fallback, anddatetime.now(timezone.utc)in the uptime subtraction.isoformat()then emits"+00:00"and the frontend's parseUTCDate uses the marker as-is. Same naive-datetime pattern surfaced in two adjacentgenerated_atfields — the storage-usage cache snapshot insystem.pyand the support bundle root insupport.py. Neither is rendered as a wall-clock timestamp in the frontend today, but both now emit tz-aware UTC for consistency so any future surface that does render them won't recreate this bug. Tests: newtest_boot_time_isoformat_carries_utc_markerregression case asserts the boot_time string ends in+00:00(orZ) — without that marker the frontend double-converts, which is exactly the reporter's symptom. Existingtest_boot_time_uses_pid1_create_timeandtest_boot_time_falls_back_to_psutil_boot_time_on_pid1_failurestill pass under the tz-aware values because1700345600is2023-11-18T20:53:20+00:00UTC, so the date-prefix assertion is unaffected. Full system API suite 21/21 green; support API 72/72 green; ruff clean. -
A1 / A1 Mini internal-code map was swapped in
PRINTER_MODEL_ID_MAP(surfaced while scoping A2L support, #1684) —backend/app/utils/printer_models.pymappedN1 → "A1"andN2S → "A1 Mini", but every other registry that names these codes —firmware_check.py(N2S → "a1"),virtual_printer/manager.py(both the model map and the serial-prefix map:N2S → "03900A"is the A1's039prefix,N1 → "03000A"is the A1 Mini's030),printer_manager.pyA1_MODELS— consistently uses the opposite (correct) direction. Any path that resolved an A1-family printer by internal code rather than serial prefix would silently misclassify. Fix: swapPRINTER_MODEL_ID_MAPtoN1 → "A1 Mini",N2S → "A1"; the matching comment inLINEAR_RAIL_MODELSwas also wrong and got the same swap (the frozenset's contents don't change — both codes were already in it — so this is cosmetic, but kept the file self-consistent). New regression test classTestA1SeriesModelIdspins both directions so a future re-flip fails loudly. Functional impact in practice is small (most A1 detection runs off the serial prefix), but the inconsistency was a footgun for any future caller that trustednormalize_printer_model_id. Backend printer-model suite 46 / 46 green; ruff clean. -
Print Queue filament-override panel showed raw 3MF base material instead of Bambu Studio's sub-brand colour name (#1718, reported by @SamNuttall) — The Print Queue's filament-override panel rendered every "Original" row as
{type} ({colorName})— just the raw 3MF<filament type="...">attribute, which is always the base material ("PLA", "PETG-HF") — plus the generic color-bucket name fromgetColorName(hex). A model sliced with "Bambu PLA Matte Charcoal" therefore showed up as "PLA (Black)" in the dropdown's original-filament option, and the schedule dialog gave no way to confirm the user was actually overriding what they thought they were. The 3MF DOES carry the Bambu SKU (tray_info_idx, e.g.GFA01) on each<filament>element —backend/app/api/routes/archives.py:3634/3665already returns it in the/archives/{id}/filament-requirementsresponse — butFilamentReqsDataatfrontend/src/components/PrintModal/types.ts:178didn't carry the field, soFilamentOverride.tsxcouldn't see it. The resolution path was also already in place:_BUILTIN_FILAMENT_NAMESatbackend/app/api/routes/cloud.py:568maps Bambu factory SKUs (GFA01→ "Bambu PLA Matte"), exposed as/cloud/builtin-filaments;/cloud/filament-id-mapreturns the same shape for user custom presets (P*prefix).KProfilesView.tsx:791already merges those two for its own labels. Fix: addtray_info_idx?: stringto theFilamentReqsData.filamentstype.FilamentOverridenow loads both maps viauseQuery(['builtin-filaments'])+useQuery(['filament-id-map'])(both shared caches the rest of the app already populates,staleTime: 5 min) and merges them into a singleidx → namelookup — user cloud preset names win over the builtin entries for the same id (the user-authored label is more specific). Both the dropdown's "original" placeholder option AND the swatch tooltip use the resolved name; the rawreq.typestays as the fallback when the SKU is unknown to both sources so unknown ids degrade to today's behaviour instead of rendering blank. Color side note: Bambu Studio's specific color names ("Charcoal") live in their cloud catalog, not in the 3MF — the file carries only the hex — so Bambuddy still renders the color fromgetColorName(hex). "Bambu PLA Matte (Black)" is the realistic best we can do; user-readable sub-brand IS now exposed. Color disambiguation (round 2): the sub-brand half above is necessary but not sufficient —getColorName(hex)resolved through/api/inventory/colors/map, which collapses every catalog entry sharing a hex to a single name via "Bambu Lab > is_default > first" priority. Hex#000000has 9 Bambu Lab catalog entries (Black for 8 materials, Charcoal for PLA Matte) all at the same priority, so "Black" — first encountered — wins the race and "Charcoal" is dropped before the frontend ever sees it. A new endpointGET /api/inventory/colors/by-material?hex=X&material=Y(backend/app/api/routes/inventory.py:get_color_by_material) preserves the material context: same case-insensitive hex match as/colors/map, then amaterialfilter on top. When no entry matches the requested material it falls back to the same priority order as/colors/map, so callers without a material hint (or with an unknown one) get exactly the existing answer — no regression for the flat-map consumers (PrintersPage, InventoryPage).FilamentOverride.tsxderives a material hint from the resolved sub-brand by stripping the leading brand token ("Bambu PLA Matte" → "PLA Matte", "PolyLite ABS" → "ABS"), dispatches oneuseQueryper slot viauseQuerieskeyed on(hex, material), and usesdata.color_name || getColorName(hex)so a slow query never blanks out the placeholder. Five new tests intest_color_catalog_extras.pypin: same hex + different material returns the correctly-paired name; unknown material falls back to priority order; missing hex returnscolor_name=null(no 404); mixed-case input on both sides matches; invalid hex (<6 chars) returns null without crashing. Three new vitest cases pin: PLA Matte Charcoal scenario lands "Bambu PLA Matte (Charcoal)", per-slot disambiguation (regression guard so a Matte slot doesn't adopt a Basic slot's answer when both share a hex), null lookup falls back togetColorName(hex). Tests overall: 20FilamentOverride.test.tsxcases green; 12test_color_catalog_extras.pyintegration cases green; combined PrintModal + FilamentOverride + FilamentMapping suite 79/79 green. Same fix applies to printer-mode FilamentMapping (round 3): the schedule modal's "Specific Printer" branch rendersFilamentMappinginstead ofFilamentOverrideand was reading the same raw fields (item.type+ genericgetColorName(item.color)) for the required-side row and the colour swatch tooltip — so a Charcoal slice opened against a specific printer still showed "Required: PLA - Black" while the model-mode branch already read "Bambu PLA Matte - Charcoal" against the same 3MF (caught when Sam's Specific-Printer screenshot still showed the old text after round 2 shipped). Extracted the three-query resolution machinery fromFilamentOverride.tsxinto a shared hookuseFilamentLabelsinfrontend/src/components/PrintModal/useFilamentLabels.tsso the two panels can't drift on label content;FilamentOverrideandFilamentMappingnow both calluseFilamentLabels(filamentReqs?.filaments)and read positional{ resolvedName, colorLabel }per slot. The hook also exports theextractMaterialHinthelper so backend material-hint test parity is mechanical (one source of truth for "strip the leading brand token"). FilamentMapping's required-side type label now reads{resolvedName}instead of raw{item.type}, and the colour swatch tooltip readsRequired: {resolvedName} - {colorLabel}instead ofRequired: {item.type} - getColorName(item.color). New vitest caserenders sub-brand + material-disambiguated colour on the required side (#1718)mirrors the FilamentOverride Charcoal scenario against FilamentMapping (msw stubs for builtin-filaments + by-material). Existing FTS dropdown-filter / force-color-match cases stay green. Hook itself gets direct unit coverage in a newuseFilamentLabels.test.tsx(11 cases — extractMaterialHint corner cases, SKU resolution, cloud-over-builtin precedence, fallbacks, positional alignment across slots with same hex but different materials, and theenabled: !!colorquery gate). The earlier "case-insensitive on both inputs" backend test (intest_color_catalog_extras.py) is rewritten to actually seed an upper-case stored hex and query it with lower-case input — the original version only checked invalid-hex returns null, which is the wrong assertion for the test name. Combined PrintModal + FilamentOverride + FilamentMapping + useFilamentLabels + useFilamentMapping suite 144/144 green; eslint clean, build clean. What this fix can NOT recover: for hexes the catalog has no entry for (third-party filament manually loaded, etc.), the color label degrades to the existing HSL-bucket name fromgetColorName(hex)— still strictly better than blank, but Bambu's specific color names only live in the seeded catalog. Frontend + backend; no migration, no new i18n keys; ruff clean, eslint clean, frontend build clean, i18n parity unchanged.
Removed
- Slicer Bundle (.bbscfg) import (#1712, reported by @IndividualGhost1905) — Bundle import never delivered what users expected. BambuStudio's "Export Preset Bundle" only includes user-customised presets; system processes / filaments are deliberately excluded by BS. So a fresh-install user who only used stock processes (the common case) got back a bundle containing their printer + maybe four custom filaments + zero processes. Importing that bundle into Bambuddy and then opening the SliceModal flipped into bundle mode — which constrained the dropdowns to bundle contents only — and surfaced "no presets" for process, blocking slicing on STL (3MF still worked because the embedded process JSON bypasses the dropdown). The first round of #1712 (
d459b6ea, 2026-05-XX) addressed cross-tier visibility / dedup / banner behaviour but didn't touch the bundle-mode dropdown trap. Investigating the second round made it clear the bundle import wasn't unlocking anything the existing tiers don't already cover — custom presets reach Bambuddy through Bambu Cloud sync, Orca Cloud sync, or Single Preset Import; standard presets come from the sidecar's/profiles/bundledroute automatically — so bundle mode was a fourth code path delivering no unique value while gating users on a slot they couldn't populate. What was removed. Backend:POST/GET/DELETE /slicer/bundles*routes,SliceRequest.bundlefield +SliceBundleSpecschema, the bundle-dispatch fork inlibrary.py::_run_slicer_with_fallback(cross-class slice-all loop, normal slice branch,_resolve_target_printer_modelshort-circuit), the bundle-context query params onGET /library/files/{id}/filament-requirementsandGET /archives/{id}/filament-requirements, the bundle-fingerprint key inslice_preview.py's LRU cache (back to(kind, source_id, plate_id, content_hash)),SlicerApiService.import_bundle/list_bundles/get_bundle/delete_bundle/slice_with_bundle, theBundleSummary/BundleNotFoundErrortypes. Frontend:BundlePicker+BundleStringDropdowncomponents,isBundleModestate and every branch on it inSliceModal.tsx,selectedBundleId/bundleProcessName/bundleFilamentNamesstate, the bundle-mode auto-pick effect, the bundle dispatch shape inbuildSliceBody, thebundlesQueryitself,SlicerBundle/SliceBundleSpectypes,listSlicerBundles/importSlicerBundle/deleteSlicerBundleAPI methods. The bundle-derived path inbuildCompatibilityIndexis also gone — the function now only takes the printer-model registry and returns{bambuModelByShortCode}.presetCompatibilitykeeps its two remaining paths: the slicer's owncompatible_printerslist on local-imported presets (authoritative when set) and the@BBL <code>name-based fallback against the printer-model registry. Tests:TestBundleRoutes/TestBundleClientMethods/TestSliceWithBundle/TestBundleAwarePreview/TestBundleDispatchShapeclasses deleted acrosstest_slicer_presets.py/test_slicer_api.py/test_slice_preview.py/test_slice_request_schema.py/test_library_slice_api.py; the SliceModal's "Bundle tier" describe block and the bundle-only assertions inslicerPrinterMatch.test.tsdeleted;SlicerBundlesPanel.test.tsxremoved;TestNozzleClassGuardsimplified (no more bundle vs preset request distinction). What replaces the Settings panel.SlicerBundlesPanelis kept under the same name and slot inSettingsPagebut now renders a static notice (title: "Slicer Bundles (removed)") explaining the removal and pointing users at Single Preset Import / Bambu Cloud / Orca Cloud, with the slicer sidecar covering stock presets automatically. The notice is permanent and can be removed in a future cleanup. i18n.settings.slicerBundles.*block replaced withsettings.slicerBundlesRemoved.{title,description,alternatives}translated across all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) perfeedback_translate_dont_fallback.slice.bundle/slice.bundleNone/slice.bundleAllRequiredkeys removed across all locales. Parity check 5106 leaves × 11 locales green. Migration. Hard cutover, no automatic preset migration. Users who previously imported bundles will see them disappear from Settings → Slicer Bundles after this drops; their printer preset still lives on the sidecar bundle store but is no longer surfaced. Standard presets from the sidecar's BBL tree cover stock slicing; users who need their customs re-upload them via Single Preset Import or sync via Bambu Cloud / Orca Cloud. Why this resolves #1712. shaddowlink's failing path was: import bundle for H2D → bundle has 0 processes (BS-side limitation) → SliceModal flips into bundle mode → process dropdown empty → can't slice STL. Post-removal: same import isn't possible, but the cross-tier preset picker shows H2D processes from the sidecar's standard tier (which always had them — bundle mode was the thing hiding them), filtered by@BBL H2Dcompatibility. STL slicing works without any user action. Tests: full backend suite 5907/5907 green; ruff clean; frontend ESLint clean;npm run buildclean; vitest 158 files / 2118 tests green; i18n parity 5106 leaves × 11 locales green.
[0.2.4.6] - 2026-06-09
Added
-
Archives page banner: reactive install-step-4 nudge for the slicer-side setting — Companion to the new
external_storagediagnostic check. The diagnostic catches the printer-side variant of "Store sent files on external storage" viahome_flagbit 11. The slicer-side variant on older BambuStudio / OrcaSlicer never reaches the printer, so the diagnostic passes even when the option is off in the slicer. The deterministic symptom is the archiver creating a row withextra_data.no_3mf_available=True(main.py:2770) — that's the signal this banner watches. New backend endpointGET /archives/no-3mf-warningreturns{has_fallback: bool}— true iff any archive in the last 30 days has the flag set AND isn't soft-deleted. The 30-day window prevents old never-fixed installs from showing the banner forever; the soft-delete filter respects the user clearing the evidence. Frontend banner sits at the top of the Archives page (amber, dismissible) — "Some recent prints couldn't be archived with thumbnails…" + link to install step 4 in the wiki. Dismissal is one-shot vialocalStoragekeyarchiveNo3MFWarningDismissed(matches the existingLayout.tsxupdate-banner pattern but persistent across sessions, since "you've been told" should outlive a browser restart). React-Query isenabled: !dismissedso the endpoint isn't polled after dismissal. 5 backend integration tests (TestNo3MFWarning) cover: recent fallback returns true, no archives returns false, archives without the flag returns false, >30-day-old fallbacks ignored, soft-deleted fallbacks ignored. i18n: 4 new keys (title,body,docsLink,dismissLabel) underarchives.no3mfBannertranslated to all 11 locales — no English fallbacks. -
Connection diagnostic now verifies install step 4 ("Store sent files on external storage") — Many users miss this setting when adding their first printer; without it BambuStudio / OrcaSlicer never leave a
.gcode.3mfon the printer's SD card, every archived print falls back to no-thumbnail / no-metadata, and the cause is invisible until the user notices the archive is empty. The trap with detecting this: on newer firmware (P2S 01.02 / Bambu Studio 2.6+) the toggle moved onto the printer itself and is pushed on MQTThome_flagbit 11 (Bambuddy already parses this intostate.store_to_sdcard). On older versions it's a purely slicer-side preference invisible to the printer. An FTP upload-probe approach was tried first — it always passed regardless of the slicer toggle because the/cachedirectory is always writable from Bambuddy's perspective; the slicer toggle only controls what BambuStudio chooses to do, not what the printer accepts from other clients. Confirmed empirically against an X1C + H2D with the slicer option toggled off (probe still succeeded,home_flagbit 11 stayed True). Fix: newexternal_storagecheck readsstate.store_to_sdcarddirectly. Pass when the printer reports the bit on, fail when off, skip when no live MQTT state or the field has never been populated (older firmware that doesn't pushhome_flag). Localised fix-text points at install step 4 with both the printer-side and slicer-side variants spelled out; theskiptext explicitly calls out the older-slicer limitation so users on that path know to verify manually. Slot in the check list sits betweenport_ftpsandmqtt_auth. 5 new tests (TestExternalStorageCheck) cover pass-on-true, fail-on-false, skip-on-disconnect, skip-on-pre-add (no state), skip-on-missing-field. The reactive symptom-side detection — a one-time banner the first time the archiver recordsextra_data.no_3mf_available=Trueafter a slicer-initiated print — is planned as a separate follow-up to cover the slicer-only setting case. Wiki updated on the System page (features/system-info.md) and the Troubleshooting page (reference/troubleshooting.md). i18n: 4 new keys (title, pass, fail, skip) localised to all 11 locales (de, en, es, fr, it, ja, ko, pt-BR, tr, zh-CN, zh-TW) — no English fallbacks. -
"Open in Slicer" desktop target is now configurable separately from the API sidecar slicer (#1329, reported by @hasmar04) — Reporter wanted to slice via the Bambu Studio sidecar but open files locally in OrcaSlicer; the existing
preferred_slicersetting drove both, so picking one forced the other. The slicer-URI flow on Workflow → Slicer literally swapped the BambuStudio handler for the OrcaSlicer one whenever the user switched the API choice. Fix: newopen_in_slicersetting ('bambu_studio' | 'orcaslicer' | null) drives only the desktop "Open in Slicer" URI handoff; the in-app SliceModal + sidecar URL routing inlibrary.py,archives.py,slicer_presets.pycontinue to usepreferred_slicerexactly as before. Default isnull— the frontend falls back topreferred_slicerso existing installs behave identically until a user changes it (no migration, no churn). Storage lives in the existingapp_settingskey/value table; the PUT path serialises a Python None as the literal string"None", and the GET path normalises it back via a new branch in_build_settings_responsematching the existingdefault_printer_idconvention — without that normalization the frontend can't tell "explicit override absent" from "explicit override set to a bogus value". Frontend: Settings → Slicer card relabels the existing dropdown's description ("Slicer used for in-app slicing via the API sidecar"), adds a new "Open in Slicer" dropdown below it with three options — "Same as API slicer" (the inherit-from-preferred default), "Bambu Studio", "OrcaSlicer".ArchivesPage(5openInSlicerWithTokencall sites),MakerworldPage(the URI handoff branch whenuseSlicerApi=false), andModelViewerModal(4openInSlicer(...)call sites) all switched from readingsettings?.preferred_slicertosettings?.open_in_slicer ?? settings?.preferred_slicer. MakerworldPage's "Slice in {{slicer}}" button label additionally branches onuseSlicerApi: when on, the label reflects the API slicer; when off, the desktop slicer — so the button text always matches what the button actually does. The OrcaSlicer "known CLI bugs" warning stays attached to the API dropdown (where it belongs — it's about the sidecar's CLI). i18n: 3 new keys in all 11 locales (de/en/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW) —settings.openInSlicerLabel,settings.openInSlicerInherit,settings.openInSlicerDescription— plus an updatedsettings.preferredSlicerDescriptioneverywhere (the old wording "Choose which slicer application to open files with" became wrong once the field stopped driving the desktop handoff). No English fallbacks per the project's hard rule. Tests: 3 new inTestOpenInSlicerOverridepin the contract — default is null, override persists across GET, explicit reset to null round-trips correctly without leaving the"None"string leak. Full backend suite green (5798/5798); frontend ESLint + build clean; vitest on SettingsPage + MakerworldPage 48/48 green; i18n parity 5095 leaves × 11 locales green. -
Queue items + Print modal now show the build plate type, per-plate accurate (#1281, reported by @CMW-ISS) — Reporter on a multi-printer farm with 40+-plate runs needed to walk to the printer with the right physical plate; the archive card had recently grown a bed-type badge, but the queue and the scheduling modal didn't. They were having to open the source 3MF in the slicer to look up which plate each queued / scheduled job needs. Backend: new
extract_bed_type_from_3mf(file_path, plate_id)helper inutils/threemf_tools.py, alongside the existingextract_filament_usage_from_3mfshape — readsMetadata/slice_info.config, finds the<plate>with the matchingindex, returns itscurr_bed_type. Whenplate_idis None it returns the first plate's value (matches the archive-level capture convention).PrintQueueItemResponsegains abed_type: str | Nonefield;_enrich_responsepopulates it fromarchive.bed_type/library_file.file_metadata["bed_type"]as the file-level default, then overrides per-plate via the new helper whenitem.plate_idis set. This matters becausearchive.bed_typeis captured at ingest as the FIRST plate's value only (seeservices/archive.py:235) — a 40-plate 3MF mixing PEI + Engineering returns "PEI" for every plate at the archive level, even though the user's plate 17 actually needs Engineering. The per-plate override re-reads the 3MF and returns the truth./archives/{id}/plates(and the library-file equivalent) now includebed_typein each plate object so the PrintModal's plate selector can render the badge inline. Frontend: queue card meta row gains a bed badge after filament weight — uses the existinggetBedTypeInfo(bed_type)helper fromutils/bedType.ts(the same one the archive card uses, so all 11 canonical bed labels + icons are covered including the BambuStudio / OrcaSlicer spelling drift). PrintModal's per-platePlateSelectorshows the bed badge under each plate's filament line; the modal header carries a bed badge for the selected (or sole) plate, surfaced before the user hits Schedule.PlateInfo+PlateMetadatatypes both get an optionalbed_typefield. No new i18n keys needed —getBedTypeInforeturns the canonical English plate name as the human label, matching the archive card's existing convention. Tests: 8 new unit cases intest_threemf_tools.py::TestExtractBedTypeFrom3mfpin the helper (single-plate, multi-plate per-plate, no-plate-id defaults to first, unknown-plate-id → None, plate-without-bed-type → None (no fall-through to another plate's value), missing slice_info, invalid file, whitespace trim). Full backend suite green (3848/3848); frontend build clean; ESLint clean; vitest on touched pages 81/81; i18n parity 5092 leaves × 11 locales green. -
Print Log page: per-row failure-cause classification (#1687 part 4, reported by @IndividualGhost1905) — Reporter clarified after part 1 shipped that what he actually wanted for point 2 was failure-cause grouping on the log (spaghetti, jam, bed-adhesion, etc.), not the archive tags I'd pointed him at. Archive
tagsdescribe the model (home decor, toys); the log row needs to describe what went wrong on a single print event. Different surface, different lifetime. What was already there:PrintLogEntry.failure_reason: String(100)already exists, gets mirrored fromarchive.failure_reasonwhen the user edits the archive (seearchives.py:1421for the mirror that ships with #1444), and the Failure Analysis widget already groups by it. So the storage and the aggregation were both done — the only gaps were (a) the Print Log table couldn't render the value because the GET serialiser silently dropped it fromPrintLogEntrySchema, and (b) orphan log entries (failures with no archive — dispatch errors, aborts before archive creation, manual entries) had no edit path at all because the Archive Edit modal can't reach them. Fix: four pieces. (1)print_log.pyGET endpoint now includesfailure_reason(andarchive_id,created_by_id) in the serialised response — pre-fix it was silently None in every response even when the column was populated. Regression guard added. (2) NewPATCH /print-log/{entry_id}endpoint accepting{failure_reason, status}, gated onrequire_ownership_permission(ARCHIVES_UPDATE_ALL, ARCHIVES_UPDATE_OWN)— same ownership shape as the per-row delete that already shipped. Backend validatesfailure_reasonagainst the same canonical vocabulary the Archive Edit modal uses (11 enumerated keys + empty-string-clears + theothercatch-all); unknown values return 400 rather than getting stored as raw garbage (the i18n layer renders the value as a key, so an unrecognised one would surface as a literal string in the UI). Status validated against the 5-value{completed, failed, stopped, cancelled, skipped}set. Empty-stringfailure_reasonstores back as NULL so the column'snullable=Trueintent is preserved end-to-end. (3)FAILURE_REASON_KEYSconstant moved to an export fromEditArchiveModal.tsxso the new editor reuses the exact same vocabulary as the archive editor — backend and frontend stay in lockstep. (4) Frontend: pencil icon added beside the existing trash icon on every Print Log row, gated onarchives:update_own/archives:update_all. Click opens a compact two-field modal (status + failure reason dropdowns). Save invalidates bothprint-logandarchives-statsquery keys so the Failure Analysis widget reflects the re-classification on the same response cycle. Failure reason is also rendered as a sub-label under the status badge in the table, mirroring the per-archivePrintLogTable.tsxconvention so the two views agree. i18n: 10 new keys (editEntryTitle,editEntryDescription,entryUpdated,entryUpdateFailed,archives.permission.noEdit, plus a 5-keystatusesblock) translated across all 11 locales — no English fallbacks perfeedback_translate_dont_fallback. Tests: 8 new backend integration cases — GET surfacesfailure_reason(regression guard for the silent-drop bug), PATCH sets / clears / rejects unknown failure_reason, PATCH updates status, PATCH rejects unknown status, PATCH returns 404 on missing ID, PATCH works on orphan entries (archive_id IS NULL) — the actual reason this endpoint exists. Full backend suite 5843/5843 green; ruff clean. Frontend vitest 2108/2108 green; ESLint + build clean. i18n parity check 5110 leaves × 11 locales green. -
Print Log page: per-row delete (#1687 part 1, reported by @IndividualGhost1905) — Reporter noted that the existing "Also remove this print from Quick Stats" toggle on archive delete is one-shot: if you tick "keep stats" at delete time, there was no later way to drop the row from /stats; and rows that aren't tied to an archive (errors, aborts, manual entries) had no delete affordance at all. Fix: every row in the Archives → Print Log table now has a trash icon next to the filament cell, gated on
archives:delete_own(own rows) orarchives:delete_all(any row), matching the archive-delete permission shape. Click → confirm modal → row is gone, and because /archives/stats aggregates overPrintLogEntrythe filament / time / cost contribution drops out of Quick Stats in the same response cycle. The matching archive (if any) is untouched — the log row is a sibling, not a child. Backend: newDELETE /print-log/{entry_id}mirrorsdelete_archive's ownership flow viarequire_ownership_permission(ARCHIVES_DELETE_ALL, ARCHIVES_DELETE_OWN); owners can drop their own rows, admins can drop any row, missing IDs return 404 rather than 200-silently. Frontend: newdeletePrintLogEntryAPI helper, per-row mutation that invalidates bothprint-logandarchives-statsquery keys so the totals re-render without a manual refresh. i18n: 4 new keys (deleteEntryTitle,deleteEntryConfirm,entryDeleted,entryDeleteFailed) translated across all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Tests: 3 backend integration cases — delete drops the row from /stats while keeping the linked archive listed, missing ID returns 404, delete-one does not touch siblings (regression guard against an accidentaldelete(PrintLogEntry)without awhere). Frontend ArchivesPage / PrintLogModal vitests stay green (31 / 31). i18n parity green (5099 leaves × 11 locales). Issue #1687 also asks for per-row tagging (already covered byEditArchiveModal's tags field) and per-row filament-usage-history edits (deferred — see the issue thread for the reasoning). -
Inventory page now supports native CSV import / export (#1576, PR #1659 by @samedyuksel) — Bulk-add spools without manually clicking through the form, and back up / migrate the local inventory in a single round-trip. Export downloads
bambuddy-spools-YYYY-MM-DD.csv(header + one row per active spool); Import shows a preview table that classifies each row as valid / error / skipped before anything hits the database, then a confirm click persists only the valid rows in one transaction (invalid rows are skipped, the user fixes them and re-uploads). Local inventory only — in Spoolman mode the buttons render disabled with a tooltip pointing at Spoolman's own CSV import/export, since the Spoolman backend has its own data store. Schema: fixed 18 columns, case- and whitespace-tolerant headers, includesweight_used,last_used, and the SpoolCreate fieldsstorage_location/category/low_stock_threshold_pctso the round-trip preserves the per-spool location data from #1291.remainingis a derived, export-only column (label_weight - weight_used, clamped at 0) — it's written for human readability and ignored on import (weight_used is the source of truth, accepting both would let them contradict). Colour resolution: explicitrgbawins, otherwisebrand + color_nameresolves against the Color Catalog (case-insensitive, single in-memory pass — no N+1); a catalog entry withmaterial = NULLis treated as the project's "matches any material" convention so a generic match counts as exact rather than firing the cross-material warning. Validation reusesSpoolCreateso every constraint that already protects manual adds (weight_used >= 0,weight_used <= label_weight,low_stock_threshold_pctrange, etc.) protects bulk imports too. Hardening: 5 MB upload cap with a structuredcsv_import_too_large413 response — Bambuddy doesn't have a global HTTP-level cap so the check lives on the route, and the implementation is a bounded 64 KB chunked read that bails the moment the accumulated body crosses the cap (file.size isNonefor chunked uploads so the loop is what actually prevents the OOM, not the pre-check). Spreadsheet formula-injection guard: every exported cell starting with=/+/-/@/ tab / CR is prefixed with a single quote on export, and the inverse strip on import keeps the round-trip lossless instead of accumulating quotes on every cycle. Soft-warn surface in the preview: aduplicate_of_existingflag fires when an active spool with the same material + brand + color_name exists (single SELECT, no N+1) so a double-click or re-upload of the same CSV doesn't silently duplicate the inventory — the row still imports (Spool has no unique constraint, by design), but the preview renders a Copy icon + tooltip so the user knows. Frontend: newSpoolCsvImportModal(file pick → preview table with per-row status / colour swatch / warnings → confirm imports valid rows) wired to Import + Export buttons on the inventory header; swatch rendering uses the existinggetSwatchStylehelper so alpha=00 shows the checkerboard underlay instead of rendering as solid black, matching the rest of the inventory surface. i18n: newinventory.csvnamespace with full translations in all 11 locales (de/en/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW). Tests: 25 backend integration cases pin every behaviour — export shape, import dry-run vs real, color resolution (catalog hit, explicit rgba wins, cross-material flagged, exact-material match, generic-material match not flagged), 5 MB rejection, weight_used bounds, formula-injection round-trip without quote accumulation, dated filename, extra-column round-trip, duplicate-warn flag. Plus 3 frontend modal tests. Full backend suite + ruff + ESLint + frontend build + i18n parity (5092 leaves × 11 locales) green. Companion docs: wiki PR maziggy/bambuddy-wiki#41 documents the schema, behaviour, and the Spoolman-mode disabled-with-tooltip semantics. -
Add Printer: scan a custom subnet for printers behind a router on a different L3 segment (#1564, reported by @MartinNYHC, root-caused by @IndividualGhost1905) — Reporter on a flat LAN couldn't add a printer that lived in a different subnet (
Bambuddy 192.168.1.0/24↔printer 10.1.1.0/24). SSDP multicast (239.255.255.250:2021) doesn't traverse routers, so the existing "Discover Printers on Network" pass found nothing; Docker mode had a CIDR text input but only as a fallback when zero interface subnets were detected, and native mode had no subnet field at all. The discovery socket has always boundINADDR_ANYso this was never an interface-bind issue — only a routing-boundary one. The fix surfaces an always-visible subnet picker inAddPrinterModal: the detected interface subnets stay as the dropdown options, plus a new "Custom subnet..." sentinel reveals a CIDR text input the user can type any reachable subnet into (10.1.1.0/24, a VLAN, a Tailscale subnet route, etc.). When custom is picked, the discovery routes throughPOST /discovery/scanwith the typed CIDR instead ofPOST /discovery/start— SSDP would no-op against a foreign subnet anyway, so this is the only behaviour that can succeed. The Scan-button label and the scanning / no-printers-found messages all key off the(isDocker || useCustomSubnet)predicate so the wording stays "Scan Subnet…" / "Scanning subnet…" — the user sees one consistent verbal model whether they're on Docker or just picked Custom. Last custom CIDR is persisted tolocalStorageunderbambuddy.discovery.customSubnetand restored on next modal open, so a user who maintains a VLAN setup doesn't retype10.1.1.0/24every time. Backend changes: none.SubnetScanner.scan_subnet()already accepts any CIDR, already caps the scan at /22 (1024 hosts) with batch-50 concurrency, and the route/discovery/scanalready takes user-supplied input — the existing plumbing was complete. i18n: 3 new keys (customSubnetOption,customSubnetLabel,customSubnetNote) translated in all 10 non-English locales (de/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW), no English fallbacks per the project's hard rule. The note text spells out the routing-boundary requirement: "The FTP (990) and MQTT (8883) ports must be reachable across the routing boundary" — a user who can pick a subnet but whose firewall blocks 8883 will at least see why the scan came up empty. Tests: 3 new inPrintersPageDiscoveryCustomSubnet.test.tsx— picker renders on native installs (was Docker-gated before), picking Custom + entering a CIDR routes throughdiscoveryApi.startSubnetScannotstartDiscoveryand persists the choice vialocalStorage.setItem, picker default (the detected interface subnet) still triggers SSDP viastartDiscovery.AddPrinterModalexported fromPrintersPage.tsxso the tests can mount it directly without round-tripping through the full page (same shape asProjectModalfor the #1642 tests). -
Orca Cloud profile sync — end-to-end integration with the slicer + SpoolBuddy surfaces (OrcaSlicer/OrcaSlicer#14028 filed for upstream allowlist broadening) — Bambuddy now reads, lists, and slices with profiles from your Orca Cloud account alongside the existing Bambu Cloud integration. OrcaSlicer 2.4.0-alpha shipped its own cloud (Supabase-backed at
auth.orcaslicer.com/api.orcaslicer.com); this integrates with it using the in-source publishable client key, a standard PKCE handshake, and the/api/v1/sync/pullprofile-sync endpoint. Four sign-in providers: Google, Apple, GitHub (paste-flow PKCE) and email+password (direct grant — Orca's web sign-in offers it even though their desktop SDK refuses); UI defaults to password with the three OAuth options listed below. UX shape: the Cloud Profiles tab is now two — "Bambu Cloud" (existing, unchanged) and "Orca Cloud" (new); the paste flow's "page will fail to load — that's expected" instruction is rendered as a prominent amber callout so the connection-refused page isn't mistaken for a Bambuddy error. The Orca Cloud tab renders the same rich profile-browser layout as Bambu Cloud (search + 5 filter dropdowns + 3-column grouped grid + click-to-detail) via a parallelOrcaCloudProfilesViewcomponent. We chose paste-flow rather than a clean OAuth callback because Orca's Supabase project only honors localhost in itsredirect_toallowlist. Slicer integration: the unified-presets endpoint surfaces Orca Cloud as a 4th tier above Bambu Cloud > local > standard;_dedupe_by_nameand the SliceModal dropdowns both updated to walk all 4 tiers. The dedicated_fetch_orca_cloud_presetsextractsfilament_typeanddefault_filament_colourinline from each profile's content (cheap because/sync/pullreturns full content per profile — no rate-limit dance like Bambu Cloud's per-setting fetch), so multi-color pre-pick scoring works against Orca presets too. A separateCloudStatusBannerinstance shows Orca Cloud's auth status independently of Bambu's. AMS slot integration:ConfigureAmsSlotModalacceptsorca_cloudas a new preset source (prefixedorca_<UUID>to match the existinglocal_*/builtin_*convention), gracefully tolerating raw UUIDs from historical saves; Orca presets are treated like local imports fortray_info_idxderivation (no Bambu setting_id, generic filament-ID map by parsed material). Slot mapping persisted withpreset_source='orca_cloud'. SpoolBuddy integration:SpoolFormModalandSpoolBuddyWriteTagPagefetch Bambu + Orca filaments in parallel viaPromise.allSettledand concat;ConfigureAmsSlotModalopens fromSpoolBuddyAmsPage's Configure flow with Orca presets surfaced first. Storage: 8 new columns onusers(5 persistent + 3 transient PKCE state with 10-min TTL), dialect-branched DATETIME / TIMESTAMP, verified on SQLite and Postgres. Auth-disabled mode falls back to global Settings table. Refresh rotation: Supabase issues single-use refresh tokens; service refreshes just-in-time (<5min leeway) and persists the new pair BEFORE the downstream call so a mid-flight crash doesn't strand the user. Cloudflare:api.orcaslicer.comis behind a UA-only gate;Bambuddy/<version>clears it (no TLS-fingerprint games). Per the bambu-compliance-outreach posture we identify honestly. Preset resolver:PresetRef.sourceextended to'orca_cloud' | 'cloud' | 'local' | 'standard';_resolve_orca_cloudlists, filters, and forwards profile content. Permissions: new explicitorca_cloud:authflag (per feedback_specific_scopes_over_folding); folded into the existingcan_access_cloudAPI-key scope (same trust dimension as Bambu Cloud — extending automatically rather than requiring a per-key opt-in). The orca_cloud router carries the same_cloud_api_key_gate+cloud_caller()deps as the Bambu Cloud router — a copy-paste miss caught only when the SpoolBuddy kiosk's API-keyed requests came back with empty preset lists from/orca-cloud/profilesbecause the plainrequire_permission_if_auth_enableddep returnsNonefor API-key callers, falling through to the global Settings table that doesn't carry per-user Orca tokens. Load-bearing gotchas surfaced and fixed during the build (captured in theorca-cloud-integrationproject-memory file so future contributors don't re-discover them): (a) Supabase silently falls back to the project Site URL when a client passes its ownstateto/auth/v1/authorize— overrides GoTrue's internal redirect_to tracking, browser lands at cloud.orcaslicer.com instead of localhost; we don't send state, PKCE alone gives CSRF protection. (b)cursor=0returns410 cursor_too_old; bare/sync/pullwith no cursor parameter is the first-sync bootstrap, same as Orca's own client. (c) The/api/v1/sync/profilesconstant is declared in source but isn't deployed — returns 404. (d) Orca'scontent.typevocabulary isprinter/print/filament, not the BambuStudiomachine/process/filamenttriplet you'd guess from the wider source; without alias mapping every printer + process profile gets silently dropped (caught against a real account showing 54 filament + 0 process + 0 printer instead of 54+18+3). (e) Naive datetimes from PostgresTIMESTAMP WITHOUT TIME ZONEcolumns get.astimezone()interpreted as local time on the read path, shifting freshly-stored pending PKCE state by the host's TZ offset and instant-firing the 10-min TTL —_as_utcnormalises on load. Tests: 32 unit tests on the OrcaCloudService (PKCE / token exchange / single-use refresh rotation / rejected-refresh-clears-tokens / JIT refresh / profile walk + content.type mapping); 6 preset-resolver orca tier tests (permission gate, content unwrap, auth error 401, not-found 400, dispatcher routing); 6 new orca-fetch tests in test_slicer_presets.py paralleling the Bambu Cloud fetcher (status vocabulary, permission shortcut, cache hit, type vocabulary); existing SliceModal vitest updated for the 4-tier shape; 6 frontend OrcaCloudView tests (all four sign-in providers + paste flow + connected + disconnect). i18n: ~35 new keys translated in all 10 non-English locales (de/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW); brand-name "Bambu Cloud" / "Orca Cloud" cognates allowlisted in the parity check; existingtier.cloudrelabelled from "Cloud" to "Bambu Cloud" everywhere it was previously generic. Service worker: bumped to v29/v28 with a forced reload-on-activate so the SpoolBuddy kiosk (Pi + Chromium + locked into kiosk mode, no devtools, no way to navigate or refresh) picks up the new bundle on a single restart instead of needing two. Verified: backend ruff clean; full pytest pass at 5648 across the suite (-n 30 in 84s); frontend eslint + build + vitest 2051 clean; i18n parity green at 5054 leaves × 11 locales. -
VP MQTT bridge surfaces why
net.info[].iprewrite didn't arm (#1429 defensive) —MQTTBridge._refresh_ip_encodinghad 4 silent early-return paths (target_client is None,printer client has no ip_address yet,no host interface shares a subnet with printer IP X and bind_address is 0.0.0.0/empty,invalid IPv4 …). When the rewrite silently no-op'd on a user's setup, the only signal was the absence of theMQTT bridge IP encoding armedINFO line — diagnosing which path was firing meant grepping the source. Each path now emits oneMQTT bridge IP encoding NOT armed: <specific reason>INFO line; the message names the actual failure (target IP, the missing-interface case, etc.). Throttled via a_not_armed_reasondedup field so an idle unarmed bridge doesn't spam one line per 30s refresh tick — only state changes log. Cleared on successful arm so a regression (e.g. printer client unbinds) re-emits the diagnostic. 5 new tests inTestNotArmedDiagnosticLoggingpin each path's specific reason text, the once-per-state-change throttle, and the arm-clears-dedup behaviour. Not a fix for #1429 itself — the bridge logic is unchanged; this just turns the silent failure into visible signal so the next "fix didn't work for me" report can be triaged in one round-trip instead of multiple. -
Connection diagnostic now verifies the printer is actually publishing on its report topic (#1622) — The existing checks proved TCP + TLS + auth + SUBSCRIBE, but a printer with a wrong-cased serial — or one that simply isn't publishing for some other reason — would still pass
mqtt_authbecause the broker accepts the subscription regardless. The user-visible symptom in that case was "AMS / K-profiles / custom filaments missing on the slicer side": the VP bridge had nothing cached to mirror because no reports ever arrived. Bambuddy already loggedConnected and subscribed, but the printer has sent zero status reports. The most common cause is a wrong or mis-cased serial number…atbambu_mqtt.py:498when this happened, but the only way to see it was to grep container logs. Newprinter_publishingcheck turns that warning into a structured diagnostic result. Pass = the bridge has seen at least one report since the latest (re)connect; fail = zero reports across the wait window with a fix-text pointing at the case-sensitive serial. The check exposesreport_messages_since_connectas a public property onBambuMQTTClientso the diagnostic doesn't reach into private state. Bounded wait with countdown UX: the bridge resets the counter to 0 on every (re)connect, so a fresh reconnect would otherwise be reported as fail before the printer's first idle push lands. The on-demand UI check polls for up to 10s (PUBLISH_WAIT_DEFAULT) at 0.5s intervals and exits the moment a message arrives — typical wall-clock is 1-2s, not the full 10. The check returnsmax_wait_secondsin itsparamsso the frontend can render a countdown next to the spinner instead of looking hung. The Connection Diagnostic modal (ConnectionDiagnostic.tsx) now displays an elapsed-seconds counter (Running diagnostic... (3s)) plus thewaitingForReportHintline (Listening for the printer to publish a status report — this can take up to 10 seconds.) during the pending state for the existing-printer flow.PUBLISH_WAIT_DEFAULT_SECONDS = 10is pinned in the frontend to match the backend constant; the 2 new i18n keys ship in all 11 locales. The support-package gathering path stays fast: it callsrun_connection_diagnosticwithoutwait_for_publish_seconds, getting an instant pass/fail with nomax_wait_secondsexposed. 6 new tests covering pass-on-reports-seen, fail-on-zero-after-wait, skip-on-disconnect, skip-on-missing-client, instant-no-wait-path, plus updated all-healthy + disconnected-state assertions to include the new check. i18n strings (title/pass/fail/skip) shipped in all 10 non-English locales with real translations — no English fallbacks per the project's hard rule. 5011 leaves × 11 locales in parity. Why this directly closes #1622: the reporter's bridge to printers 2 + 4 (P1S + A1 Mini real targets) repeatedly hit keep-alive timeouts and force-reconnected; on every reconnect the printer published nothing in the stale window, leaving the VP cached state empty. The slicer Device tab pulls AMS / cali_id / custom filaments from cached state — empty cache = empty dropdown. The reporter's H2D bridge stayed healthy throughout and its slicer Device tab populated correctly. The in-app Connection Diagnostic had passed (port_mqtt: pass,mqtt_auth: pass) because it didn't observe publish behaviour. The new check catches this class of failure on the user's first try.
Changed
-
Slicer sidecar now ships as pre-built images on GHCR + Docker Hub — install works on QNAP / Synology / Container Station (#1657, reported by @d3nn3s08) — Reporter on QNAP QTS 5.2.9 hit three install failures in sequence: the official
slicer-api/docker-compose.ymlusedbuild: { context: https://github.com/maziggy/orca-slicer-api.git#bambuddy/profile-resolver }, which requiresgitin the Docker BuildKit worker — Container Station and Synology DSM don't ship git there, so the build fails immediately withexec: "git": executable file not found. Manual ZIP-as-local-context workaround tripped a QNAP filesystem quirk in the systemd post-install (Failed to copy permissions from /etc/group). Fallback toghcr.io/afkfelix/orca-slicer-api:latest-orca2.3.0ran but couldn't slice — that image lacks thebambuddy/profile-resolverpatches (theinherits:chain resolver, thefrom: "User"→"system"rewrite, the#clone-prefix strip, and the sentinel-value strip), so/profiles/bundledreturned 400 and/slicereturnedInvalid parameter value(s) included in the 3mf file. The fix removes the build-from-source requirement entirely. Both sidecar images are now built locally on Martin's box and pushed to two registries (ghcr.io/maziggy/orca-slicer-api,docker.io/maziggy/orca-slicer-api, and the same two forbambu-studio-api) via a newdocker-publish-sidecars.shhelper in theorca-slicer-apirepo; the stable Bambuddy publish script auto-invokes it after each release, and the beta script too. Daily-beta opts in only via--include-sidecars(slicer rebuilds are expensive). The helper has hard safety guards: aborts unless the orca-slicer-api repo is onbambuddy/profile-resolverAND the working tree is clean, and never executesgit checkout/pull/fetch/resetitself.slicer-api/docker-compose.ymlswitches frombuild:toimage: ghcr.io/maziggy/orca-slicer-api:${SIDECAR_TAG:-latest}. NewSIDECAR_TAGenv var in.env.exampledefaults tolatest; setSIDECAR_TAG=bambuddy-X.Y.Zto pin to the sidecar image that shipped with a specific Bambuddy release. Scope limitation: both images arelinux/amd64only. The OrcaSlicer multi-arch path stays on hold pending an upstream extraction fix — the kldzj/orca-slicer-arm64 AppImage's--appimage-extractsilently fails under QEMU build emulation; the Dockerfile's;-chained RUN block masked the failure until the finalCOPY squashfs-roottripped. ARM64 hosts (Pi 4/5, Apple Silicon Linux) should run the sidecar on a separate x86_64 box and point Bambuddy at it via the Sidecar URL field — the sidecar doesn't need to live next to Bambuddy. Docs aligned:slicer-api/README.mdandwiki/features/slicer-api.mdrewrote the Quick start, Updating, and Sidecar source sections —docker compose up -dnow pulls instead of building, anddocker compose pull && docker compose up -dis the new update path (no--no-cache --pulldance because Compose only ever seesimage:references). The build-from-source path stays documented as an advanced option under "Building from source (advanced)" for forks / dev work. -
VP access code is now auto-derived from the target printer in non-proxy modes (Discord report) — A user on Discord set up a Queue-mode VP with a different access code than the real target printer and couldn't get the slicer to connect, even after the cert-trust path was sorted. Root cause: the live target-printer mirror that landed earlier in the 0.2.5 cycle forwards the slicer's MQTT/RTSPS auth bytes through to the real printer — the slicer holds one code in its profile (the one it bound the VP with), and that code has to pass two checks (VP listener, then real printer). If the codes diverge the bridge silently fails at the second hop and the slicer abandons the connection (e.g. opens 8883, FINs before sending a ClientHello). The wiki did document a code-match requirement but framed it as a camera-only concern (
MQTT and FTP work either way; only the camera path needs the match) — wrong, all bridged protocols inherit. The fix removes the foot-gun rather than re-document it. When a target printer is selected on a non-proxy VP (Archive / Review / Queue), the access-code field in the VP card switches to a read-only display showing the target's code with an Eye-toggle reveal, and the backend auto-inherits the value on everycreate/update(any explicitaccess_codesubmitted alongside a target is silently overridden — belt-and-braces for non-UI clients). When no target is set, the field stays editable as before. The sameinheritsAccessCodeFromTargetpredicate gates a small "Inherited from target" badge in place of the existingisSet/notSetstatus pill. Changing the target after the slicer has already bound triggers an info toast ("Access code now matches the new target — re-add this device in your slicer") because the slicer's stored code is now stale. One-shot startup migration incore/database.pycorrects any pre-existing mismatched VPs on first boot after the upgrade: SELECTs the diverged rows for an INFO log per VP (VP 'Workshop Queue' (id=3) access code synced from target printer 'X1C #2'— audit trail for anyone digging through logs), then UPDATEs via correlated subquery (idempotent — the WHERE clause excludes already-synced rows, so re-running is a no-op; portable across SQLite and Postgres). No user-facing banner because there's no action for the user to take — the fix is done, and a previously-stuck bridge now works. Wiki:features/virtual-printer.mdline 1189 flipped from the wrong MQTT/FTP-work-either-way claim to "the bridge forwards slicer auth bytes through; Bambuddy auto-derives so the codes can't diverge", the line-84 tip's "for camera" framing replaced with the broader rule, and the port-table row for RTSP:322annotated with "transparent passthrough to the real printer's:322, same end-to-end TLS as proxy mode" so the dedicated-bind-IP-vs-passthrough-to-printer apparent contradiction reads as one consistent model. i18n: 5 new keys (accessCode.inheritedFromTarget,accessCode.derivedFromTargetHint,accessCode.reveal,accessCode.hide,toast.targetCodeChangedRebind) translated in all 11 locales (de/en/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW), no English fallbacks per the project's hard rule. -
File Manager sidebar: "All Files" now scopes to your own uploaded files; new "External" entry holds the combined linked-folder view (#1621, reported by @kcw96) — Reporter linked a NAS share that auto-imported hundreds of 3MFs, and from then on their handful of Bambuddy-uploaded files was lost in the "All Files" listing — no filter, no toggle, only per-folder clicks to escape the noise. Restored the pre-external semantics so long-time users get their muscle memory back: "All Files" lists managed-storage files only (
is_external=False), exactly what it meant before external folders existed. The combined "everything across every external mount" view moves to a new sibling sidebar entry, External, which only renders when at least one external folder is linked (zero-cost on installs that don't use the feature). Per-folder clicking is unchanged: clicking any folder in the tree — internal or external — still shows that folder's contents directly. Backend:/api/v1/library/filesgains two mutually-exclusive query flags,internal_onlyandexternal_only, filtering directly onLibraryFile.is_external. Both-flags-set is a 400 (catches frontend regressions immediately instead of silently picking one). Folder- or project-scoped requests bypass both flags because they already imply a single scope. Frontend: newtopLevelView: 'internal' | 'external'state onFileManagerPage, defaultinternal; the query passes the corresponding scope only whenselectedFolderId === null. Sidebar shows the "External" row gated onfolders.some(f => f.is_external); mobile selector dropdown carries a__top:internal/__top:externalsentinel so the same state can round-trip through<option value>. Empty-state copy distinguishes "no internal files yet" from "no external files" so a user staring at an empty External view doesn't think their NAS is broken. i18n: 3 new keys (allExternal,externalIsEmpty,externalEmptyDescription) translated in all 10 non-English locales. Tests: 3 new backend integration tests intest_library_api.py(internal-only with mixed root + folder + external file mix, external-only across two NAS mounts, mutually-exclusive 400) and 3 new frontend tests inFileManagerPage.test.tsx(External entry conditional onis_external, default internal-only query, External-click switches scope). Existing 48FileManagerPagetests + 11FileManagerExternalFoldertests stay green. Behaviour change for the small set of users who relied on the combined view as default: clicking "External" once gets the previous union behaviour (across-all-externals); clicking a specific external folder still shows just that mount, same as before. -
Empty AMS units no longer trigger hourly humidity/temperature notifications (#1619) — The hourly AMS sensor recorder in
backend/app/main.py::record_ams_historyfanned out humidity and temperature alarms for every AMS unit above threshold without checking whether the unit was actually loaded with filament. Empty AMS units still report ambient sensor readings, so users with one loaded AMS and one empty one got useful alarms for the loaded unit and steady noise for the empty one every hour. The reporter's workaround (disable all AMS humidity notifications) also killed the useful alarms — not a real choice. New_ams_has_filament(ams_data)helper inspects the firmware-reportedtray_exist_bitshex bitmap (one bit per tray slot,"0"/"00"= empty unit) with a fallback to thetrayarray'stray_typestrings for early-pushall shapes where the bitmap is missing. The recorder gates the alarm dispatch on this check per-AMS-unit, so a multi-AMS printer with one loaded + one empty still alarms on the loaded one. Sensor history still records regardless of the gate so the System page humidity/temperature charts stay continuous — the only thing the gate suppresses is the outbound notification. 9 unit tests intest_ams_alarm_gating.pycover the bitmap-zero-is-empty case, single/multi/all-loaded variants, thetray_exist_bitsmissing → tray-array fallback, garbage bitmap → fallback, blank bitmap → fallback, non-string bitmap → fallback (Bambu sometimes sendsint), whitespace-onlytray_typenot counting as loaded, and defensive non-dict tray entries. -
Inventory:
/reset-usagerenamed to/reset-consumed-counter; UI label is now "Reset counter" — The old endpoint name implied that calling it would dropweight_usedto 0; in practice it only stampsweight_used_baseline = weight_usedso the Inventory page's "Total Consumed" widget (which rendersweight_used - baseline) reads 0 going forward, while remaining (label_weight - weight_used) is preserved. Calling the endpoint via curl and seeingweight_usedunchanged in the JSON response is confusing — the name didn't describe what the endpoint actually does. New paths: internal/api/v1/inventory/spools/{id}/reset-consumed-counter+/spools/reset-consumed-counter-bulk, Spoolman-mode/api/v1/spoolman/inventory/spools/{id}/reset-consumed-counter+/spools/reset-consumed-counter-bulk. Behaviour is unchanged in both modes: internal stamps the baseline directly; Spoolman-mode PATCHes upstreamused_weight=0and the_map_spoolman_spoolread mapping at_spoolman_helpers.py:252-268reconstructs the samedisplayed consumed = 0, remaining unchangedBambuddy-visible shape — Bambuddy-side parity between modes (per feedback_inventory_modes_parity) was already in place before this rename and is preserved. The Spoolman-client methodreset_spool_usagekeeps its name because it describes what's sent upstream to Spoolman, which has not been renamed. Frontend:api.resetSpoolUsage/bulkResetSpoolUsage(and Spoolman variants) renamed toresetSpoolConsumedCounter/bulkResetSpoolConsumedCounter. Button labels switch from "Reset usage to 0" to "Reset counter" / "Reset all counters" — short and unambiguous; tooltips and confirm-modal bodies still spell out the full semantics ("zero the consumed-grams counter; remaining weight is not changed"). i18n: 9 keys renamed (resetUsage*,resetAllUsage*,usageReset,allUsageReset,resetUsageFailed→resetConsumedCounter*,resetAllConsumedCounters*,consumedCounterReset,allConsumedCountersReset,resetConsumedCounterFailed); real translations shipped in all 10 non-English locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) per the hard-rule against English fallbacks. Tests:test_spool_reset_usage.py(9 tests, internal mode) and 3 reset-related tests intest_spoolman_inventory_api.pyupdated to hit the new paths; behavioural assertions unchanged. Breaking change for external API consumers that already wired/reset-usage— no compat shim shipped because the old name actively misled callers; the migration is a one-line URL swap. -
docker-compose.yml: bridge-mode warning about the 1001-port FTP passive range + docker-proxy RAM footprint (#1646, reported by @TheFou) — Reporter on bridge mode (Docker defaultuserland-proxy: true) saw ~2000docker-proxyhost processes spawn from the commented"50000-51000:50000-51000"line, pinning ~3.5 GB of host RAM before they had even logged in for the first time. Linux's host-mode default in the same compose file sidesteps this entirely (zero docker-proxy cost) — the issue only fires when a user forces bridge mode (typically macOS/Windows / Docker Desktop). The 1001-port range itself is load-bearing on the VP server side (virtual_printer/ftp_server.py:567-574documents the widening from 100 ports as multi-VP collision-avoidance headroom; reverting would regress that), so the fix is documentation, not code. Added a warning block above the commented FTP-passive line pointing bridge-mode users at{ "userland-proxy": false }in/etc/docker/daemon.json— the reporter confirmed this clears the issue on their setup. Kernel does NAT directly via iptables/nftables in that mode, no per-port host process needed; only side-effect is that connections originating from 127.0.0.1 on the host itself can't reach the container, which doesn't matter for nearly every Bambuddy install. -
AMS drying now enabled for H2C starting at firmware 01.02.00.00 — H2C was previously in
_DRYING_UNSUPPORTED_MODELSalongside the A1 family. Moved to_DRYING_MIN_FIRMWAREwith the same01.02.00.00floor as H2S / P2S. Both SSDP model codes the H2C advertises (O1C,O1C2— single- and dual-nozzle variants) get the same firmware gate so thesupports_drying()check fires correctly regardless of which form is in the printer record. Test coverage extended inTestSupportsDrying: H2C / O1C / O1C2 cases added to the with-firmware pass set, the old-firmware fail set, and removed from the unsupported-models loop.
Fixed
-
PostgreSQL restore from a SQLite backup no longer deadlocks against the print scheduler (reproduced 2026-06-09 restoring a native install's backup into a fresh Docker+Postgres deploy) — Reporter (Maziggy) backed up the native install, brought up the new Docker image against an external Postgres, hit Restore in Settings → Backup. ~2 seconds in, the restore aborted with
asyncpg.exceptions.DeadlockDetectedError: Process X waits for AccessExclusiveLock on relation 109940; Process Y waits for RowExclusiveLock on relation 110182. Root cause: the existingclose_all_connections()step before the DB swap only disposes the SQLAlchemy engine's connection POOL — the asyncio tasks that USE the engine keep running. Theprint_scheduler.run()loop (30 s cadence) andsmart_plug_manager._snapshot_loop()(30 s cadence) wake up after the dispose, callasync_session(), lazily reopen a pool connection, and start a normal transaction that grabsRowExclusiveLockonprint_queue/smart_plug_energy_snapshots. The restore'sDROP TABLE IF EXISTS public.<tbl> CASCADEpass in_import_sqlite_to_postgresneedsAccessExclusiveLockon every public table — AB/BA lock-order conflict, classic Postgres deadlock, restore transaction rolled back. The log confirms:13:44:53,669restore begins →13:44:53,680print_scheduler fires queue check →13:44:55,607smart_plug_manager fires snapshot →13:44:55,607deadlock detected. The existing code already pausedvirtual_printer_managerbefore restore for file-lock reasons; the other timer-based DB writers were missed. Fix — two layers. (1) Beforeclose_all_connections(), pause the four most active timer-based DB writers via their existing stop affordances:print_scheduler.stop(),smart_plug_manager.stop_scheduler(),notification_service.stop_digest_scheduler(),await background_dispatch.stop(). Thenawait asyncio.sleep(1.0)to let in-flight loop iterations commit and release their sessions before the engine pool gets disposed. We don't restart the services on success because the restore handler already tells the user to restart Bambuddy to pick up the new DB. (2) Belt-and-braces inside_import_sqlite_to_postgres: prependSET LOCAL lock_timeout = '10s'to the begin-block before theDROP TABLE CASCADEpass, so any residual writer that slips through the pause window (per-printer MQTT clients writing reactively to state changes, the hourly AMS history recorder firing inside the restore window, etc.) surfaces a fastlock_timeouterror instead of producing a fresh deadlock or hanging the restore for 30+ seconds.SET LOCALis transaction-scoped so the global default applies to every other DB caller. Scope clarification: there are ~12 background services started at lifespan startup; the four paused here are the ones with the tightest cadences. Slower-cadence services (github_backup_service,local_backup_service,library_trash_service,archive_purge_service, AMS history, runtime tracking, SpoolBuddy watchdog, camera cleanup) all fire on hour-or-longer intervals and are statistically very unlikely to land inside a few-second restore window; the lock_timeout layer catches them if they do. Tests:test_restore_sqlite_wal_safety.pyandtest_settings_api.pyintegration suites (53 tests) stay green on the edited handler; ruff clean; runtime smoke (from backend.app.services.X import Y+hasattr+iscoroutinefunctioncheck) confirms all four stop signatures match the patch's sync/async mix. -
Configure Slot now keeps the active K-profile on reopen for assigned-but-unconfigured slots (#1689 follow-up, reported and patched by @Spionkiller01) — After the original #1689 fix shipped, Spionkiller01 found a residual case: on a slot that's physically loaded but unconfigured (filament inserted, but the printer hasn't bound a preset yet —
tray_type="",tray_info_idx="", noslot_preset_mappingsrow), the first open of Configure Slot showed the right K-profile, but closing it with the X and reopening it dropped back to "default 0.020". Clicking "Configure slot" (Apply) once persisted it, but the user shouldn't have to. Root cause: the original #1689 cali_idx safety net was unreachable on this code path.matchingKProfilesinConfigureAmsSlotModal.tsx:751early-returned[]whenselectedPresetInfowas null — andselectedPresetInforesolves to null exactly when there's no resolvable slot preset (unconfigured slot, no mapping row). The "always include the slot's currently-active K-profile by cali_idx" branch lives past the main name+id matcher, so it never ran from the no-preset path. On first open a freshly-cached preset briefly let the safety net trigger; on reopen the live slot state had no preset, returned[], the auto-select effect saw no candidates, the modal fell back to default 0.020. Fix (verbatim from Spionkiller01's H2C-tested diff, with the existing extruder guard): split the early return into two — still short-circuit on missing kprofilesData, but whenselectedPresetInfois null andslotInfo.caliIdx > 0, find the active profile byslot_id === activeIdx(extruder-matched when known) and return it as a single-item list. The auto-select effect downstream then pre-selects it on reopen with no extra change. Strictly additive: with a resolvable preset present the existing matcher runs untouched; withcaliIdx === 0 || nullthe function still returns[](no unrelated profiles leak in). Tests: new vitest casesurfaces the slot's active K-profile when no preset is resolvable (#1689 follow-up)exercises the path withtrayType='', nosavedPresetId, andcaliIdx=6against a K-profile fixture atslot_id=6— asserts the dropdown surfaces it. Verified the test fails without the patch (stash → run filter → fail; pop → run → pass). The existingcaliIdx === 0guard test continues to pass under the new branch. Full ConfigureAmsSlotModal vitest 24/24 green. Credit: @Spionkiller01 for spotting the residual edge case after merge, producing the diff, and testing live on an H2C —Co-Authored-Byon the commit. -
K-profile matching now prefers filament_id over parsed names — surfaces custom profiles in the spool form AND fixes Configure Slot showing "default 0.020" for an actively-bound K-profile (#1688 + #1689, both reported and diagnosed by @Spionkiller01 with concrete H2C testing; #1689 also reported by @IndividualGhost1905) — Two related symptoms on different UI surfaces, same root cause. #1688: spool form's PA-profile suggester (
frontend/src/components/spool-form/PAProfileSection.tsxviaisMatchingCalibrationinspool-form/utils.ts) only matched K-profiles by parsing the profile name for material/brand/variant. Spools already storeslicer_filament(the slicer preset's id) and K-profiles already carryfilament_id, but both were ignored — so a user's custom K-profile whose name doesn't agree with the slicer preset's name got silently dropped from the suggestion list even when the underlying filament_id was identical. #1689: ConfigureAmsSlotModal's K-profile filter (matchingKProfiles) ran the same name-only logic on the slot's selected preset — a spool assigned under "Generic PLA" with a custom K-profile actively bound on the printer landed in the modal as "K profile not assigned, default 0.020 will be used", while the printer-card hover-card correctly showed the active profile. The hover-card and the Configure Slot modal disagreed because they used different lookup paths; the modal's path was the one with the name-parse filter. The shared root cause: spool preset ids and K-profile filament_ids look different but are equivalent after normalisation. Spools storeslicer_filamentas the cloud setting_id form ("GFSG98_09" —_09is the variant suffix, the "S" infix marks it as a setting_id); K-profiles storefilament_idas the bare form ("GFG98"). Plain===doesn't match; both need normalising first. This conversion already exists in the other direction atbuildFilamentOptions(filament_id → "GFS" + filament_id.slice(2) for setting_id), so the inversetoFilamentIdhelper isn't speculative — it's just the matching reverse. Fix — one shared helper, two surfaces: new exports infrontend/src/components/spool-form/utils.ts—toFilamentId(id)normalises both shapes by dropping the "_NN" variant suffix and stripping the "S" in "GFS" (so both "GFSG98_09" and "GFG98" yield "GFG98");isGenericFilamentId(id)flags Bambu's genericGFx99ids (GFL99 = generic PLA, GFG99 = generic PETG, etc.) which are shared across many physical filaments and must NOT id-match (they over-match and obscure brand-specific profiles — name fallback handles those correctly). Then: (1)isMatchingCalibrationaccepts a newslicer_filament?: stringformData field, tries id-match first (with generic exclusion), falls through to the existing name parse —PAProfileSectionalready passes the fullformDataso no caller edit needed. (2)ConfigureAmsSlotModal.selectedPresetInfonow also resolves afilamentId(viatoFilamentId(cp.setting_id)for cloud presets;toFilamentId(builtinFilamentId)for builtin; empty for local/orca paths that fall through to name match);matchingKProfilesadds the id-match check at the top of the per-profile predicate, then keeps the existing name logic, then always unshifts the slot's currently-active K-profile (byslot_id === slotInfo.caliIdx, gated onactiveIdx > 0so caliIdx=0/null doesn't leak unrelated profiles in, and extruder-matched when known) — covers the #1689 case where the spool was bound under a generic preset but the active profile lives under a different filament_id entirely. The "always include active" branch is Spionkiller01's #1689 diff verbatim, gated more tightly. SpoolBuddy coverage: both K-profile surfaces in the kiosk UI reuse the shared components —SpoolBuddyWriteTagPagerenders<PAProfileSection>(auto-fixed viaisMatchingCalibration),SpoolBuddyAmsPagerenders<ConfigureAmsSlotModal>(auto-fixed viamatchingKProfiles). No kiosk-specific edits required; the shared helpers carry the fixes through. (SpoolBuddyCalibrationPageis scale calibration, unrelated;InventorySpoolInfoCardis display-only.) What this does NOT change: spools without a slicer_filament, K-profiles without a filament_id, and generic GFx99 ids all fall through to the existing name-based matching path — strictly additive precedence, no behaviour change for the name-only cases that already worked. The new id-match never causes a miss the old code would have caught. Tests: 21 new vitest cases —isMatchingCalibration.test.ts(18 cases) pins thetoFilamentIdround-trip in both directions (GFSG98_09 → GFG98 and back is identity-preserving for the cloud→K-profile flow), the genericGFx99exclusion, falsy/non-Bambu id pass-through (numeric local-preset id, Orca UUID), and the id-match-wins-over-name behaviour including the spool's reported"GFSG98_09" ↔ K-profile "GFG98"real-data scenario.ConfigureAmsSlotModal.test.tsx(3 cases) pins the modal-level behaviour: a custom K-profile name surfaces when filament_id matches (#1688 in-modal), the slot's active profile is always included even with no name/id match (#1689), and thecaliIdx == 0guard prevents unrelated profiles from leaking in via the safety net. Full frontend vitest suite: 2108 / 2108 green. ESLint clean on touched files; frontend build clean. Credit & dispatch: @Spionkiller01 diagnosed both issues with concrete data (theGFSG98_09 ↔ GFG98normalisation case is theirs), tested both patches live on an H2C, and explicitly offered to PR. Landed verbatim with adjustments (shared helper, tighter active-profile guard) andCo-Authored-By. @IndividualGhost1905 also reported #1689 independently and identified its connection to #1688. -
Tabs no longer go silently zombie after the JWT expires — auth-expiry now redirects to /login on the same tab (#1698, reported by @TCL987, fix patched in reporter's fork) — Reporter on X1C, Docker install, left a Bambuddy tab open past the 24 h JWT lifetime. After expiry: navigation between pages still worked, but every API request silently failed, leaving the UI looking like every list was empty. A manual refresh was needed to land on
/login. Root cause:AuthContext.userstays stale after the JWT clears. When a 401 with a token-invalidating message (Token has expired,Could not validate credentials,User not found or inactive,Invalid API key,API key has expired) lands infrontend/src/api/client.ts:154-167, the handler callssetAuthToken(null)to drop the token from sessionStorage / localStorage — butAuthContext.useris a React state value that was populated once at mount viacheckAuthStatus()→/auth/me, andsetAuthToken(null)doesn't reach into AuthContext's React tree.ProtectedRoute(App.tsx:101) only redirects whenuser === null, so the protected tree keeps rendering, every subsequent request goes out with no Authorization header, the backend 401s, and the UI shows nothing. A page refresh remountsAuthProvider,checkAuthStatus()finds no token,setUser(null)fires, the redirect runs — which is what the reporter ended up doing every 24 h. The 3 othersetAuthToken(null)call sites all live insideAuthContextitself and pair withsetUser(null)directly, so no cross-module signal was needed for them; theclient.ts:165site was the only one missing the React-tree notification. Fix (mirrors the reporter's fork patch deec96d1): aftersetAuthToken(null)inclient.ts, dispatch awindow.dispatchEvent(new CustomEvent('auth:expired'))(guarded ontypeof window !== 'undefined'for SSR / test safety).AuthContext's mountuseEffectadds awindow.addEventListener('auth:expired', handleAuthExpired)listener whose handler callssetUser(null)after amountedRef.currentguard, and removes the listener in the effect's cleanup so unmount → remount doesn't double-bind.ProtectedRoutethen seesuser === nullon the next render and runs<Navigate to="/login" replace />immediately, no manual refresh needed. What this intentionally does NOT change: generic401 Authentication requiredresponses (without a token-invalidating message) still don't clear the token or fire the event — they're treated as transient timing issues, exactly asclient.ts:155's pre-existing comment documents. So a one-off 401 from a race during login won't redirect a working session. Listener cleanup means tests / dev hot-reload don't accumulate handlers. Tests: 4 new vitest cases —client.test.tsgains "dispatches 'auth:expired' event on 401 with invalid token message" and "does not dispatch 'auth:expired' on 401 with generic auth error" (both usevi.fn()listeners onwindowto assert the event fires/doesn't fire).AuthContext.test.tsxgains a newauth:expired event (#1698)describe block — "clears user when an auth:expired event is dispatched" simulates the login → expiry → event → user-null flow end-to-end viasetAuthToken('valid-token')(the canonical setter; writing to sessionStorage post-import wouldn't propagate to the module-levelauthTokenvariable initialised at import time), and "does not crash when the event fires after unmount" pins themountedRefguard so the listener can't trigger a state-update-after-unmount warning. Full frontend vitest suite: 2087 / 2087 green. ESLint clean on touched files. Frontend build clean. Credit to @TCL987 for diagnosing this and shipping the working fix on their fork before opening the issue. -
Filament usage no longer over-counts when printing one plate from a multi-plate 3MF (#1697, reported by @volodymyr-doba) — Reporter on P1S printed a single lid (~190 g grey PETG) from
gridfinity-storage-box-5x4x6.gcode.3mf(a multi-plate file with 5×box + 5×lid plates) and the spool's Usage History recorded 242 g of grey + 31 g of black — the whole file's filament total, not the dispatched plate. The print took 5 h 47 m which matches the lid alone, and the queue card correctly previewed 190 g, but the spool got debited for everything. Root cause: usage tracking parsed the 3MF without a plate filter.extract_filament_usage_from_3mf(file_path, plate_id)inbackend/app/utils/threemf_tools.pyalready supports filtering and the queue's pre-flight capacity check atapi/routes/print_queue.py:254/:286passesitem.plate_id, but the two completion-time recorders did not:_track_from_3mfinservices/usage_tracker.py:907(internal Filament Inventory) andstore_print_datainservices/spoolman_tracking.py:223(Spoolman mode) both called the extractor with no plate_id and summed every plate. Perfeedback_inventory_modes_parityboth modes had to ship in the same drop, AND per the verification pass after the initial implementation: the direct-Print path (api.reprintArchive/api.printLibraryFilewithplate_id: selectedPlateinPrintModal/index.tsx:739/750) hits the same bug because it never goes through the queue — caught before merge by tracing the frontend dispatch surface end-to-end. Fix — two complementary captures: (1)PrintSessiongains aplate_id: int | Nonefield;on_print_startqueriesPrintQueueItemfor the printer's currently-printing row and recordsqueue_item.plate_idonto the session — covers the queue path. (2)register_expected_printinmain.pyaccepts a newplate_idparameter and stores it in a parallel_print_plate_ids: dict[int, int]dict (mirror of_print_ams_mappings);background_dispatch.py's 2 register sites andprint_scheduler.py's 1 register site now pass plate_id (the dispatch already resolved it via_resolve_plate_id; reordering the resolve to run before register is a no-op since the resolver is pure). At expected-print promotion,main.pyinjects_print_plate_ids[archive_id]into_active_sessions[printer_id].plate_id(only when the session has no plate_id yet — queue captures win), mirroring the existingams_mappinginjection pattern. The dict drains onon_print_completeand on TTL eviction of the matching_expected_printsentry — same lifecycle as_print_ams_mappings. (3)_track_from_3mfaccepts a newplate_idkwarg, threads it fromsession.plate_id, and passes it toextract_filament_usage_from_3mf. (4)store_print_dataaccepts aplate_idkwarg; the 3 call sites inmain.pypass_get_start_plate_id(archive_id)(new helper, parallel to_get_start_ams_mapping); withinstore_print_datathe caller value wins, falling back toqueue_item.plate_idfor the queue path. The PrintArchive'sfilament_used_gramsstays file-level summed by design (#1593's contract — the archive describes the file, not the run); only the per-run usage attribution becomes plate-aware. What this intentionally does NOT touch: for direct Print of a single-plate file,_resolve_plate_idreturns 1 → registered asplate_id=1, which extracts plate 1 = the whole file — identical to the prior no-filter behaviour. The change is observable only for multi-plate 3MFs where a specific non-first plate was dispatched. Tests: 9 new acrosstest_usage_tracker.py+test_spoolman_tracking.py+test_print_start_expected_promotion.py— plate_id propagation through_track_from_3mf; absence leaves itNone; on_print_start captures queue_item.plate_id; on_print_start no-op when no queue item; Spoolman-mode plate-scoped extract;register_expected_printstoresplate_idin_print_plate_ids;_get_start_plate_idreads it back; injection into session for direct-Print (no queue capture); guarded against overwriting an already-captured queue plate_id. The pre-existingtest_prefers_explicit_ams_mapping_over_queue_mappingupdated for the new unconditional queue lookup (was conditional, now always queries to capture plate_id). Full 5830-test backend suite green. Ruff clean across the entire backend, not just touched files. -
AMS slots with a spool loaded but no material configured now show "?" instead of "Empty" (#1694, reported by @kleinwareio) — On a 3-AMS P1S the reporter's screenshot showed AMS-C slots labelled "Empty" even though spools were physically loaded; OrcaSlicer's Device view showed the same slots as loaded. Root cause: the compact label below the AMS slot circle in PrintersPage rendered
tray.tray_type || t('ams.slotEmpty'), falling back to "Empty" whenever the printer firmware hadn't been told which material is in the slot. The codebase already had agetEmptySlotKindhelper that distinguishes'physical'(firmware confirmed empty via state 9/10) from'reset'(tray_type absent but firmware hasn't confirmed empty — i.e. spool loaded, just unassigned). The hover-card / circle border already used that distinction (line 814+ comment); the compact label did not. Fix: label now branches onemptyKind—'physical'keeps "Empty" (the firmware-confirmed empty case),'reset'shows "?" (matching the slicer's own convention for "loaded but unknown material"). External / VT tray label is unchanged (external trays have no "configured/unconfigured" distinction — they're either loaded or not). The SpoolBuddy kiosk'sAmsUnitCardwas carrying the same bug and got the same fix (mirror ofgetEmptySlotKind, "?" vs "Empty" label, tooltip "Spool loaded — slot not configured"). i18n: newams.slotUnconfigured: '?'key added to all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) — value is universal so it's identical in every locale. The existingams.emptySlotReset = 'No filament assigned'tooltip surface in FilamentHoverCard already covers the "what does this mean" question on hover, so no new tooltip key needed for the main card. Tests: AmsUnitCard vitest gainsshows "?" for loaded-but-unconfigured slot (#1694)pinning both branches in one render (one slot withstate: 9→ "Empty", one with no state → "?"). Existing AMS tests stay green (9/9 SpoolBuddy AmsUnitCard; AMS load/unload page tests untouched and green); i18n parity green (5100 leaves × 11 locales); frontend build clean; ESLint clean. -
Virtual Printer MQTT no longer disconnects idle OrcaSlicer at keepalive×1.5 (#1548 round 2, reported by @hollajandro) — Round 1 (commits
b6636053+4ffefa60) shipped the keepalive parser + 1.5× idle disconnect per MQTT spec §4.4 and a per-minute status-push diagnostic. Reporter's follow-up pcap proved the round-1 logic was correct as designed, but exposed the actual root cause: the same OrcaSlicer install which stays connected to a real Bambu P1S indefinitely sends zero MQTT packets after the initial CONNECT / SUBSCRIBE / pushall / get_version burst — no PINGREQ at all — so any §4.4-compliant server disconnects it atkeep_alive × 1.5. Real Bambu firmware does not enforce §4.4 (verified: the reporter's identical Orca install holds an idle session against real hardware on the same network), so spec compliance is itself the regression. Fix: after CONNECT/auth, drop the application-level read timeout entirely (read_timeout = None) and setSO_KEEPALIVEon the underlying socket so the OS TCP stack detects truly dead connections within a few minutes. The 60 s pre-CONNECT timeout is preserved — a client that opens TCP but never sends CONNECT still gets reaped to prevent half-open resource leaks. Negotiated keepalive is still parsed and now logged at INFO ("MQTT client … authenticated (negotiated keepalive=Xs, idle disconnect disabled)") for support-bundle visibility. Tests: TestHandleClientIdleConnection addstest_idle_client_stays_open_past_one_and_a_half_times_keepalive(negotiates keep_alive=2, sits idle for 4 s, asserts handler still running and writer not closed — direct round-1 inversion),test_so_keepalive_set_on_socket_after_connectpinssetsockopt(SOL_SOCKET, SO_KEEPALIVE, 1)runs on the wrapped socket the moment auth succeeds. PINGREQ test docstring updated since there's no longer a timeout for it to "reset". All 33 VP MQTT server tests green; ruff clean. After this ships, OrcaSlicer should stay connected to the VP indefinitely while idle and reconnect cleanly on real network drops. -
System page now reports the container's uptime / boot time, not the host's (#1690, reported by @IndividualGhost1905) — Reporter on Proxmox LXC observed that System → Uptime / Boot Time matched the Proxmox host's values, not the container's. Root cause:
psutil.boot_time()reads/proc/stat:btime, which on shared-kernel containers (Docker, LXC) is the host kernel's boot time — leaking the host's lifecycle into Bambuddy's UI. Fix: read PID 1's create_time instead —psutil.Process(1).create_time()returns the POSIX timestamp of the init/entrypoint process, which in a container is the container's start time, and on bare metal / VMs is the host init (effectively identical topsutil.boot_time()within a sub-second). Defensivepsutil.Error/OSErrorfallback to the oldpsutil.boot_time()for the rare case where /proc/1/stat is unreadable (locked-down container, custom seccomp policy). No frontend / i18n change — the field shape is unchanged, only the value is now correct on container installs. Tests: 2 new integration cases — one pins that the route readsProcess(1).create_timeand that the response uses that timestamp (notboot_time), the other pins the fallback path via a realpsutil.NoSuchProcess(1)so the endpoint still returns 200 with the best-available answer. All 8 pre-existing system-info tests updated to also mock the new code path; full system API suite 20/20 green; ruff clean. -
Profile editor filament type dropdown now lists PLA-CF and the other Bambu CF / GF / specialty materials (#1686, reported by @Bgabor997) — Creating or editing a filament preset on the Profiles page (BL Cloud, Orca Cloud, and Local Profiles all open the same shared editor) only offered 11 base materials (PLA, ABS, PETG, TPU, PA, PA-CF, PET-CF, PC, ASA, PVA, HIPS). Reporter on P1S wanted to tag a custom preset as PLA-CF — the dropdown source had no entry, so the saved preset's
filament_typewas wrong and the printer received the wrong material code at dispatch. Root cause:backend/app/data/filament_fields.json(served byGET /cloud/fields/filamentand consumed byProfilesPageviagetCloudFields) shipped a curated subset that pre-dated Bambu's CF/GF lineup expansion. Other surfaces in the codebase already named the canonical list (utils/filament_ids.pyGENERIC_FILAMENT_IDS,spool-form/utils.tsMATERIALS, the Bambu filament-id catalog incloud.py), so the gap was specifically in the editor's allowed-values JSON. Fix: expanded thefilament_typeselect to 25 BambuStudio-aligned options grouped by family — PLA (+ CF/GF/AERO), PETG (+ CF), ABS (+ GF), ASA (+ CF/GF), PC, PCTG, PA family (+ CF/PAHT-CF/PA6-CF/PA6-GF), PET-CF, TPU, PPS family (+ CF/GF for X1E), PVA, HIPS. No frontend, no i18n (material codes are universal). K-profiles editor unaffected — it picksfilament_id, notfilament_type. Tests: 15 unit cases intest_filament_fields_options.pypin every newly-added variant (PLA-CF, PLA-GF, PLA-AERO, PETG-CF, ABS-GF, ASA-CF, ASA-GF, PCTG, PAHT-CF, PA6-CF, PA6-GF, PPS, PPS-CF, PPS-GF) plus the baseline-must-still-be-present guard so a future curation pass can't silently drop them. -
Native systemd install no longer fails when INSTALL_PATH is under /home (#1685, reported by @Geoff-S) —
bambuddy.serviceshipped withProtectHome=true, which makes/home/*invisible to the service namespace. When the user installed into/home/bambuddy/(instead of the default/opt/bambuddy/), theExecStart=/home/bambuddy/venv/bin/uvicornpath couldn't be resolved at exec time and the unit failed withstatus=203/EXEC: Unable to locate executable. TheReadWritePaths=$INSTALL_PATHdirective doesn't reliably re-expose/home/*subpaths for executable resolution. Fix:install/install.shnow detectsINSTALL_PATH == /home/*and emitsProtectHome=read-onlyfor that case; the default/opt/bambuddy/install keeps the stricterProtectHome=true. The manualdeploy/bambuddy.servicetemplate defaults toProtectHome=read-onlywith a comment explaining when to tighten it totrue.read-onlykeeps/homeimmutable to the service (no security regression — the service can read its venv but not write anywhere outside theReadWritePathsallowlist). -
VP settings card now shows the target printer's serial in proxy mode — On a proxy-mode VP, the runtime services (SSDP advertisement, MQTT bind identity, certificate subject) all use the target printer's actual serial via
target_printer_serial or self.serial(manager.py:235, 941, 957), but the/api/v1/virtual-printersresponse — which feeds the VP settings card — always returned the self-generated suffix-based serial from_get_serial_for_model(model_code, vp.serial_suffix). The card therefore displayed a serial that didn't match what the bridge actually advertises and what the slicer sees, breaking the visual "one identity per VP" mental model. Fix:_vp_to_dict(api/routes/virtual_printers.py:77) is now async and acceptsdb; whenvp.mode == VP_MODE_PROXY and vp.target_printer_id, it issues a singleSELECT serial_number FROM printers WHERE id = vp.target_printer_idand substitutes the result into the responseserialfield. Archive / queue / review modes keep the self-generated serial — those modes synthesise their own identity and never speak the target's. Defensive fallback when the target row is missing (printer deleted mid-config, manual SQL tweak, race between delete-printer and read-VP): the response falls back to the self-generated serial so the card still renders and the user can fix the target, rather than the API 500-ing. All 4_vp_to_dictcall sites (list, create, get, update) updated toawaitwithdb. Tests: 3 new inTestVirtualPrinterSerialSurface— proxy VP returns target serial across all three response paths (create / get / list), non-proxy VP with a target still uses the self-generated serial, orphaned proxy VP falls back to self-generated. Full VP API suite stays green (34/34); VP unit suite stays green (126/126); ruff clean. -
Print modal now exposes a "Nozzle Offset Calibration" toggle for dual-nozzle printers (#1682, reported by @louiskleiman) — Reporter on H2D running diamond nozzles: BambuStudio exposes a per-print "Nozzle Offset Calibration" option that is incompatible with diamond hot ends, but Bambuddy had no way to control the same flag, so every dispatch silently set it to the firmware default. Root cause: the field was hardcoded.
bambu_mqtt.py:3445always wrote"nozzle_offset_cali": 2(skip) into the MQTTproject_filepayload, regardless of model, regardless of any user choice. The wire format is tri-state —1=run,2=skip — and matches BambuStudio's encoding; the manual-calibration route (/printers/{id}/calibration) already wired the correspondingcali_idx=2MQTT command, but the dispatch-time toggle was simply absent. For most users this was invisible (BambuStudio's default is "run" on H2D / H2D Pro / H2C / X2D, Bambuddy's default was effectively "skip"), but a diamond-nozzle setup that needs the calibration explicitly off had no way to confirm Bambuddy's behaviour or override it the other way once we add a toggle that follows the slicer's default. Fix: end-to-end plumbing ofnozzle_offset_caliwith a hard MQTT-layer gate on dual-nozzle.start_print()(bambu_mqtt.py:3300) gains anozzle_offset_cali: bool = Falsekwarg and the project_file payload line becomes"nozzle_offset_cali": 1 if (nozzle_offset_cali and is_dual_nozzle) else 2. The dual-nozzle check reusesis_dual_nozzle_model()and the runtime_is_dual_nozzleflag (set whendevice.extruder.infohas ≥ 2 entries) — same canonical signal the rest of bambu_mqtt.py uses for routing decisions. Even if a stale queue item from when the printer was misidentified carries the flag, the MQTT layer downgrades it to2so firmware never tries to calibrate a head it doesn't have. The kwarg threads throughprinter_manager.start_print(), bothbackground_dispatchcall sites, andprint_scheduler._start_printso every dispatch path — direct reprint, library file, queue-dispatched, watchdog-recover — respects the per-item setting. Persistence:print_queue.nozzle_offset_calicolumn (BOOLEAN DEFAULT TRUE, branched onis_sqlite()because Postgres rejectsDEFAULT 1for BOOLEAN, caught by my Postgres test environment before this shipped) — default TRUE matches BambuStudio's behaviour on dual-nozzle, the MQTT gate makes the value a no-op on single-nozzle. Newdefault_nozzle_offset_calisetting (default TRUE) plumbed throughschemas/settings.py, the settings PUT allowlist, and the SettingsPage card — the row in Settings → Default Print Options only renders whenprinters.some(p => p.nozzle_count === 2), so single-nozzle-only users never see a control they can't act on. ReprintRequest + FilePrintRequest schemas (schemas/archive.py,schemas/library.py) carry the field too so the API surface is consistent across the three "send 3MF to printer" routes. Frontend:PrintOptionsPanel(components/PrintModal/PrintOptions.tsx) accepts ashowDualNozzleOptionsprop and filters the option list;PrintModal/index.tsxcomputes it fromselectedPrinters.some(p => p.nozzle_count === 2)in printer-mode or from a small inlineDUAL_NOZZLE_MODELSset in model-mode (mirrors the backendDUAL_NOZZLE_MODELSfrozenset:H2D,H2DPRO,H2C,X2D). The same gate flows throughQueuePagebulk-edit — the new tri-state toggle only renders if any registered printer hasnozzle_count === 2. Labels reuse the existingsettings.defaultBedLevelling/settings.defaultFlowCali/ etc. translation keys (identical strings, already translated) to keep i18n churn proportional to the actual new copy. i18n: 3 new keys per locale × 11 locales = 33 entries —settings.defaultNozzleOffsetCali,settings.defaultNozzleOffsetCaliDesc,queue.bulkEdit.nozzleOffsetCali— real translations in every locale (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallbacks. i18n parity check confirms 5069 leaves × 11 locales. Tests: 4 new intest_bambu_mqtt.pypin the four-quadrant gate: default value (P1S, no kwarg →2), single-nozzle ignore (P1S, kwargTrue→ still2— the safety net), dual-nozzle honour (H2D,True→1), dual-nozzle false (H2D Pro,False→2— the diamond-nozzle case).test_printer_manager.pyupdated for the new kwarg inassert_called_once_with. Frontend tests: existing PrintModal / QueuePage / SettingsPage suites pass with the new field threaded through (117 / 117). Full backend suite: 3840 / 3840 pass. ruff clean; frontend build clean; ESLint clean; i18n parity green. -
"Assign Spool" no longer claims the AMS slot was configured when it wasn't (#1680, reported by @kleinwareio) — Reporter clicked Assign Spool from the printer card for AMS-B slot 4 while that slot was empty. The toast said "Spool assigned and AMS slot configured" but the AMS card kept showing slot 4 as Empty. Root cause: misleading toast on the empty-slot deferred-config path. The backend (
inventory.py:1385-1405) deliberately skips the MQTTams_filament_settingpublish when the AMS reports an empty tray state (state ∈ {9, 10}) because Bambu firmware silently drops the push for empty slots — there's no point sending a command the printer will discard. The assignment row is persisted withpending_config=true, andon_ams_change(main.py:1031-1054) re-fires the full configuration the moment the AMS reports a non-empty fingerprint in that slot. The flow is correct; the success log linePre-configured assignment: spool 16 → printer 1 AMS1-T3 (slot empty, will configure on insert)confirms the backend did exactly that. But the frontend ignored the response flag.AssignSpoolModal.tsx:153always calledshowToast(t('inventory.assignSuccess'), 'success')— the wording "Spool assigned and AMS slot configured" — regardless of whether the backend actually configured the slot or deferred. The sibling SpoolBuddy modal (spoolbuddy/AssignToAmsModal.tsx:212-226) already branched onpending_configand showed a distinct "Slot will configure when you insert the spool" message; the printer-card modal was just never updated to match. Fix:AssignSpoolModal.tsxnow readsnewAssignment.pending_configand picks between'inventory.assignSuccess'(slot configured immediately) and the new'inventory.assignPendingInsert'("Assigned. Slot will configure when you insert the spool.") key. Spoolman-mode branch unchanged — the Spoolman backend route always sends the MQTT push (no pending_config flag is exposed) and the SpoolBuddy modal's existing comment documents that. i18n: newinventory.assignPendingInsertkey in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), translations copied verbatim from the existing parallelspoolbuddy.modal.assignPendingInsertentries so the message reads identically across the app. No English fallbacks per the project's hard rule; i18n parity check confirms 5066 leaves × 11 locales. Tests: 2 new inAssignSpoolModal.test.tsx—shows the pending-insert toast when backend returns pending_config=true (#1680)pins the new branch (slot-was-empty case the reporter hit), andshows the configured toast when backend returns pending_config=false (#1680)is the counterpart regression guard so a future refactor can't silently mark every assign as pending. Both also assert the WRONG toast is NOT also called (defense against accidental double-toast). 16/16 AssignSpoolModal tests pass; frontend build clean; ESLint clean. -
Restarting Bambuddy mid-print no longer marks the live archive as "cancelled / aborted" + duplicates it + double-counts filament (#1679, reported by @IndividualGhost1905) — Reporter on X1C, daily build
v0.2.5b1-daily.20260607: a print was running, the host was restarted (planned reboot / power outage / watchtower image update), and Bambuddy's printer card showed the print as cancelled while the printer continued printing happily. Print log showedabortedfor that row, filament usage was deducted at the cancellation moment (48.6 g / 5 % in the supplied screenshots), and when the print actually finished a second archive was created and filament was deducted again. Net effect: filament inventory off by the entire print weight, statistics showing one "user-cancelled" entry alongside one "completed" entry for the same physical print. Second confirmed hit from the same reporter, plus a corroborating comment from @Arn0uDz on watchtower-driven restarts. Root cause: connected-edge reconciliation fired on a bare MQTT-connected state that had no real data yet. On Bambuddy startup, a freshBambuMQTTClientis constructed withPrinterStatedefaults — most importantlystate.state = "unknown"andstate.subtask_name = "". The MQTT_on_connectcallback (bambu_mqtt.py:668-669) broadcastson_state_change(self.state)immediately after the broker accepts the connection — BEFORE the_request_push_allround-trips with the printer's real status.on_printer_status_change(main.py:825) seesstate.connected=Trueflip on the connected-edge, spawnsreconcile_stale_active_printsfor that printer. The reconcile walks every archive instatus="printing", calls_is_active_archive_stale(main.py:3352) — which seesstate.state="UNKNOWN"(skips the IDLE/FINISH/FAILED branch), thenstate.subtask_name=""(matches trigger 3, "printer subtask_name empty") and returns stale. A synthesisedabortedPRINT COMPLETE fires for every in-flight archive on every printer, clears_active_prints, and when the real PRINT COMPLETE finally arrives at print end,_active_printsdoesn't have the entry, so a brand-new archive row is created instead of overwriting the synthesised one. The pre-existing comment at_is_active_archive_stale("the next real PRINT COMPLETE would have overwritten the status anyway") was wrong: the reactive completion handler uses_active_printsfor lookup, not a join on filename/subtask_id, so the original row stays cancelled and a duplicate is born. Timing-dependent in practice — on hosts where the printer's firstpush_statusresponse wins the race against the reconcile background task, state is real and reconcile doesn't false-positive; on slower hosts or busy MQTT brokers, the bare-connect-edge fires first and the bug hits. The reporter is on a slower-race host and saw it twice. Fix: two-layer guard. (1) Primary:on_printer_status_changenow gates the reconcile spawn onstate.statebeing a real value —state_known = bool(state.state) and state.state.upper() not in ("", "UNKNOWN")— so reconcile doesn't fire until the firstpush_statusupdatesstate.stateto a real Bambu firmware value (RUNNING / IDLE / FINISH / PREPARE / SLICING / PAUSE / FAILED). When that real push arrives,on_printer_status_changefires again, the connected-edge flag is stillFalse(we never set it), and reconcile runs against actual evidence. The existing #1542 mechanism — synthesising a missed PRINT COMPLETE for prints that finished during a disconnect window — keeps working: if the printer reportsIDLEon its first real push after reconnect, reconcile catches it the way it always did. (2) Belt-and-braces:_is_active_archive_stalenow returns(False, "")whenstate.stateis empty /"unknown"/None, regardless of the subtask fields. Strictly more conservative than the previous behaviour; only suppresses the degenerate-input false positive. Any future caller that bypasses the primary gate still can't synthesise an aborted completion from defaults. Tests:test_reconcile_stale_active_prints.py26 cases (up from 21) — new parametrizetest_pre_push_state_returns_not_stale_even_with_empty_subtaskpins all five degenerate forms ("unknown","UNKNOWN","Unknown","",None) and asserts none triggers stale even with emptysubtask_id+ emptysubtask_name. The existing #1542 regression coverage stays green — terminal-state, subtask-id-mismatch, and empty-subtask-name-under-RUNNING all still report stale on real state pushes. Full backend suite: 3836 / 3836 pass. ruff clean. -
Print queue no longer wedges in "Currently Printing" when a printer accepts
project_filebut never starts (#1678, reported by @kleinwareio) — Reporter on two P1S, one was power-cycled mid-print and came back online; from then on Bambuddy showed the next queue item as "Currently Printing" at 0% while the printer card showed "Idle / Ready to print". The same file also re-appeared in the Queued list as Pending after the user resubmitted. Only restarting the Bambuddy container ever recovered it. Support log + screenshots confirm: at dispatch time MQTTproject_filewas ACK'd, printer pushedgcode_state=IDLE, gcode_file=<our-file>, subtask_id=<our-submission-id>— i.e. the file landed on the printer but the printer never transitioned IDLE → PREPARE → RUNNING. Root cause:_watchdog_print_startreturned SUCCESS as soon assubtask_idadvanced. The subtask_id-as-pickup-signal was added for H2D, which can sit atFINISHfor ~50 s after acceptingproject_filebefore flipping to PREPARE (#1078) — but it's strictly a "command landed" signal, not "actually printing". When the printer accepts the file but then wedges (cloud+LAN re-auth dance after a power cycle, old firmware, partial network outage), the watchdog returned success, the queue row stayed atstatus='printing', the in-memory_expected_printsentry stayed registered (TTL is 2 hours and only clears the dict, not the DB row), and every subsequent queue item was blocked because the printer was still "in flight". This reporter's firmware (01.07.00.00, current is 01.08.x+) andbambu_cloud_token-enabled cloud+LAN mode make the post-power-cycle wedge measurably more likely on their box, but the queue-wedge bug applies to any printer that accepts a file but stalls before starting. Fix: split the watchdog into two phases. Phase A (up totimeout, default 90 s, unchanged behaviour) waits for either an active-state transition OR asubtask_idadvance — if neither happens the publish was lost on a half-broken MQTT session (#887/#936) and we revert + force-reconnect (the original #967 recovery path). Phase B (new, up tophase_b_timeout, default 180 s) only runs when Phase A exited via subtask_id-alone: keep watching for the active-state transition. 180 s is ~3.5× the worst observed H2D FINISH → PREPARE delay (#1078), so the H2D path stays green. If Phase B times out the queue item is reverted topendingso the user can retry without restarting Bambuddy — and Phase B explicitly does NOT force a MQTT reconnect because subtask_id-advance proves the project_file landed and a forced reconnect mid-parse triggers 0500_4003 (#1150). Phase A's existinggcode_file-changed discriminator (#1150) stays put for the no-subtask-id-advance case. Tests:test_scheduler_watchdog.py14 cases (up from 13) — the #1078 H2D regression test rewritten to step the status through Phase A (subtask_id advance with state=FINISH) then Phase B (state flips to RUNNING) and pin success; newtest_reverts_when_subtask_advanced_but_state_never_activepins the #1678 wedge case (subtask_id advances, state stays IDLE for the full Phase B window → revert + NO force_reconnect call); newtest_default_phase_b_timeout_is_180_secondspins the new default so a future refactor doesn't silently shrink the H2D headroom. Existing #967 / #1150 / #1370 / disconnect / fallback / discriminator regression coverage all stays green. Wider scheduler + queue + dispatch test surface (305 tests) stays green; ruff clean. -
Service-worker activate handler no longer hangs first-install browsers (demo site stuck spinner + Firefox Corrupted-Content) — Reproduced live on the demo platform: a visitor lands on
{session}.demo.bambuddy.cool/, the Printers page renders, but clicking any sidebar entry sticks the next page on a spinner; only a manual reload recovers. In Firefox the same race surfaces as a "Corrupted Content Error" withsw.jsstuck inactivatingfor the entire session. Root cause: theclient.navigate(client.url)call added to theactivatehandler insw.js(commit18d534c9, shipped 2026-06-04 alongside the Orca Cloud landing) was intended to force kiosks running an old SW to reload after a deploy, but its only guard wasclient.url && typeof client.navigate === 'function'— neither distinguishes a first install from an upgrade. On every fresh origin (every demo session is a new subdomain, but also any browser visiting Bambuddy for the first time, or after clearing site data) the activate handler still fired the forced navigation: Chromium raced it against React Router's in-flight SPA mount and wedged the page; Firefox'sevent.waitUntildeadlocked onawait client.navigate(...)because the SW intercepts its own document fetch while stillactivating, the document load aborts, and the SW never reachesactivated. The "first install on a never-controlled client" guard the commit's comment claimed simply didn't exist in code. Fix: split the lifecycle correctly.sw.jsactivate handler is reduced to cache cleanup +clients.claim()(matches the standard PWA lifecycle and lets activation complete in low single-digit ms regardless of in-flight document state). The deploy-pickup reload moves tosw-register.js: capturehadController = !!navigator.serviceWorker.controllerat script load (true ⇔ a previous SW was controlling the document), listen forcontrollerchange, and onlylocation.reload()whenhadControllerwas true. A returning kiosk hits a new deploy → had a controller → reloads as before. A first-install visitor (no prior SW, or hard-refresh, or first demo session) → no controller → no forced navigation → React mount completes cleanly.CACHE_NAMEbumpedbambuddy-v29 → bambuddy-v30andSTATIC_CACHEbambuddy-static-v28 → bambuddy-static-v29so existing browsers fetching the newsw.jsdrop the old CacheStorage in the same pass — without the bump the SW file byte content might equal the cached one and the upgrade installs nothing. The SpoolBuddy-kiosk unregister branch at the top ofsw-register.jsis unchanged (still wipes registrations on/spoolbuddypaths). Thenotificationclickhandler insw.js(open-tab-on-push) still usesclient.navigate(url)— different code path, unrelated, unchanged. -
VP archive/queue names with
&no longer render as&amp;+ tooltip corrected for BambuStudio 2.7.x reality (#1658 follow-up, reported by @IndividualGhost1905) — Two bugs surfaced on the same screenshot set: (A) Metadata-mode archive and queue names showedPCB Vise &amp; Solder Stationwhere the 3MF's Title metadata isPCB Vise & Solder Station. Root cause:ThreeMFParser._parse_3dmodel(backend/app/services/archive.py:495-538) parsed the XML<metadata name="Title">…</metadata>payload via regex and stripped whitespace but never calledhtml.unescape(). The raw&landed in the DB; React then auto-escaped the&again on render, producing&amp;. The sibling parserProjectPageParser(line 754) already had a loop-until-stable unescape and a comment explaining why ("content is often triple-encoded" — observed BambuStudio behavior), the makerworld-fields path just didn't share it. Fix: module-levelimport htmland the same loop-until-stable unescape pattern in_parse_3dmodel, applied uniformly to all<metadata>values soTitle,Designer, and any future fields all get peeled the same way. The loop terminates as soon ashtml.unescape()stops changing the string, so single-, double-, and triple-encoded payloads all converge to the correct value; plain ASCII passes through untouched. (B) Filename-mode showed the slugified project title (PCB_Vise_&_Solder_Station) instead of the user-typed Send-dialog text ("Main Parts"). This is NOT a Bambuddy bug — BambuStudio source confirms it.PrintJob.cpp:314-325(src/slic3r/GUI/Jobs/PrintJob.cpp) readsBBL_DESIGNER_MODEL_TITLE_TAG(defined as"Title"inbbs_3mf.hpp) from the 3MF, slugifies it (space →_, unusable chars<>[]:/\|?*"→_, collapse runs of_, truncate to 100 chars), and unconditionally overwrites the user-typedm_project_namewith it before sending.params.project_namebecomes both the FTP filename and the MQTTsubtask_name. The user-typed string never leaves BambuStudio when a Title metadata exists — there is no MQTT field carrying it, so Bambuddy has no recovery path. The previous tooltip ("handy if you renamed the job in the 'send to printer' dialog") promised something BambuStudio strips, and the previous reply to the reporter dismissed this as "OrcaSlicer-style upload, working as designed" which was wrong on BambuStudio 2.7.1.57. Fix: tooltip rewritten in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) to spell out the BambuStudio behavior — both modes often produce the same string because BS overwrites the Send-dialog name with the 3MF Title field when present. Tests: 3 new intest_archive_service.py::TestThreeMFMetadataHTMLUnescape—Titlewith&unescapes to&(the reporter's exact case),Titlewith triple-encoded&amp;amp;peels all three layers (the BambuStudio worst-case ProjectPageParser already documents), plainTitle=Benchypasses through unchanged (regression guard against accidentally munging non-encoded payloads). Full 104-test archive suite green; ruff clean; i18n parity holds (5065 leaves × 11 locales); frontend build clean. -
FTP passive-port pool now sliced per-VP (10 ports each) so bridge-mode Docker drops from ~3.5 GB to ~210 MB host RAM (#1646, reported by @TheFou — followed up with corrections we acted on) — Reporter on a Linux Docker VM (
network_mode: hostnot viable because other containers already bind the same ports) measured 2002docker-proxyhost processes spawned from the previously-exposed50000-51000:50000-51000range — one process per port per address family, ~3.5 MB RSS each, ~3.5 GB total that doesn't show up indocker statsbecause it's host-level not container-level. Root cause: shared port pool, treated as symptom not cause.VirtualPrinterFTPServerexposedPASSIVE_PORT_MIN/MAXas class constants (backend/app/services/virtual_printer/ftp_server.py:573-574), so every VP's FTP session passed the same(50000, 51000)range into_bind_passive_portand competed on the same 0.0.0.0 binds. The widening from 100 → 1001 ports in an earlier round had been collision-avoidance headroom for multi-VP-on-shared-bind, but the cost was paid by every install — including the reporter's single-VP install that only ever needed ~10 ports of headroom. Fix: per-VP non-overlapping slices, allocated by VP id. New module-levelcompute_passive_port_slice(vp_id) → (port_min, port_max)returns a 10-port window: VP id 1 → 50000-50009, VP id 2 → 50010-50019, …, VP id 100 → 50990-50999. Class constants are gone;VirtualPrinterFTPServer.__init__now takespassive_port_min/passive_port_maxinstance args.manager.pycomputes the slice at server-construction time fromself.idand passes it in. Result for the reporter (single VP): 10 exposed ports → 20 docker-proxy processes → ~70 MB instead of ~3.5 GB. Three VPs → 30 ports → ~210 MB. Wrap-around behaviour pinned: VP ids beyondPASSIVE_MAX_SLOTS = 100wrap modulo 100 (an install that's churned through many VPs over time still produces a valid in-range slice). A same-slot collision (vp_id 101 lands on the same slice as vp_id 1) falls back to the per-session 10-attempt random retry that pre-#1646 code already had — same recovery, no regression. Compose default narrowed:docker-compose.ymlnow exposes50000-50029:50000-50029by default (covers 3 VPs out of the box) instead of the 1001-port range. The comment explains how to widen for more VPs (50000-500N9forN = vp_count - 1) and that proxy-mode VPs still need50000-50100:50000-50100because proxy mode forwards the real printer's full range — that codepath uses a separateTCPProxy.FTP_DATA_PORT_MIN/MAXand isn't sliced (the real printer owns that range, not Bambuddy). Doc corrections in the same drop: the previous warning over-stateduserland-proxy: falseas "confirmed by the reporter" — TheFou had flagged it as theoretical, not tested; the new comment doesn't push it as a recommendation at all (it's a global daemon flag, too blunt for a per-container problem). The new comment also explicitly names Linux multi-service hosts (NAS, dedicated Docker VMs, Unraid, Synology DSM) as a primary bridge-mode audience instead of leaving the warning under a "macOS/Windows" framing that TheFou pointed out missed his use case. Acknowledges that host-mode default is a deliberate trade-off for SSDP discovery, not a security-blind default. Tests: 10 new intest_vp_ftp_port_slicing.py—compute_passive_port_slicepins: vp_id=1 starts at base, consecutive vp_ids get adjacent non-overlapping slices, no two distinct vp_ids within MAX_SLOTS share a port (exhaustive across all 100 slots), wraps modulo MAX_SLOTS, top slot stays within the documented pool, non-positive vp_ids clamp to slot 0 (defensive — never produce a negative port that would crashasyncio.start_server). TwoVirtualPrinterFTPServerinstance tests pin: two instances constructed with different slices stay independent (regression guard against re-introducing class-level state), default-arg construction yields a valid one-slice window. Existing proxy-mode test attest_virtual_printer.py:2269(101 ports for_ftp_data_proxies) stays green — that path is unchanged. Full 130-test VP suite green. -
Print Log "User" column now shows the user for prints started from the Queue (#1670, reported by @JmanB52D) — Reporter on a P2S with auth enabled, Virtual Printer in Queue mode and Auto-dispatch off: a user uploads a
.3mfto the VP (FTP, anonymous), then logs into Bambuddy and clicks ▶ on the staged queue item to start it; the print finishes and the PrintLogEntry's User column is blank. Same setup with the VP in Archive (slicer-initiated) mode correctly attributes the user. Root cause: two-link gap on the Queue→manual-start dispatch path. (a)POST /queue/{id}/start(print_queue.py:1039) auth-protected, but the route's user dep was bound to_and discarded — the clicker was never recorded. (b)PrintScheduler._start_print(print_scheduler.py:1886) dispatches the queue item directly and never callsprinter_manager.set_current_print_user(...). The print-complete callback (main.py:3513) reads_print_user_info = printer_manager.get_current_print_user(printer_id)— which is only ever populated bybackground_dispatch.py:747/943(the Archive→Print and Library→Print flows). Queue dispatch had no equivalent hop, so_print_user_infowas alwaysNoneand the PrintLogEntry'screated_by_usernamelandedNULL. Fix (two-sided): (1)print_queue.py /startnow binds the auth dep touser: User | Noneand writesitem.created_by_id = user.idwhenuser is not None AND item.created_by_id is None— credits the clicker on VP-uploaded (unattributed) items without overwriting existing attribution from UI-added queue items (matches the standard "first claim wins" ownership rule inauth.py::require_ownership_permission). (2)print_scheduler.pygains a small_propagate_owner_to_printer_managerhelper, called from_start_printimmediately afterregister_expected_print: whenitem.created_by_idresolves to a real User row, it forwards(printer_id, owner.id, owner.username)intoprinter_manager.set_current_print_user. No-ops cleanly when the item has no owner (auto-dispatched VP items intrinsically) or when the user row is missing (e.g. user deleted between queue-add and dispatch — the print log row falls back to un-credited rather than crashing the dispatch). Tests: 6 new intest_queue_start_user_attribution.py— three route tests pin (a) authenticated/startwritescreated_by_idon an unattributed item, (b) an existing owner is preserved when a different user clicks/start, (c) auth-disabled leavescreated_by_id=NULL(no synthetic placeholder user invented); three helper tests pin (d) the propagation forwards the resolved username intoset_current_print_user, (e) aNoneowner is silently skipped, (f) a missing User row is silently skipped instead of raising. Full 63-testtest_print_queue_api.pysuite stays green. Backend ruff clean. -
AMS drying popover's "Start Drying" button is no longer hidden behind iOS Safari's bottom URL bar on iPhone (#1669, reported via in-app bug report, iPhone 17 Safari) — Reporter could see the temperature / duration sliders and the "Rotate spool during drying" checkbox but couldn't reach the orange Start Drying button at the bottom of the popover — only a thin sliver of it was visible just above Safari's URL bar. Root cause: the popover sizes its
maxHeightagainst CSS100vh(PrintersPage.tsx:5443) and positions itself usingwindow.innerHeight(viacomputePopoverPosition,popoverPosition.ts:53). On iOS Safari both of those report the layout viewport — the full screen ignoring the bottom URL/toolbar overlay — not the visual viewport. The popover therefore extends behind Safari's bottom toolbar and the footer button gets clipped. Earlier iterations of the same surface (#1447 popover-off-bottom, #1458 footer-scroll-reachability) fixed desktop / normal-viewport cases but assumed100vhmatched the visible viewport. Fix: two-line change. (a)frontend/src/pages/PrintersPage.tsx:5443switchesmaxHeight: calc(100vh - …)→calc(100dvh - …)so the dynamic viewport units shrink with iOS toolbars. (b)frontend/src/utils/popoverPosition.ts:53defaultsviewportHeightfromwindow.visualViewport?.height ?? window.innerHeightso the flip-above decision also uses the actually-visible area; the existing optional override still wins (tests keep their explicit viewport values). Result: when the iOS toolbar is up, either the popover flips above the trigger earlier (visualViewport too short for below-placement), or the body scrolls within a capped maxHeight and theshrink-0footer stays pinned to the visible bottom — the Start Drying button is reachable in both cases. Tests: 3 new inpopoverPosition.test.ts::computePopoverPosition (#1669)— flip-above triggers when visualViewport.height (700) is shorter than innerHeight (800) and the trigger position would only overflow under the visual viewport; falls back to innerHeight when visualViewport is unavailable (older WebViews / jsdom); an explicitviewportHeightoverride still wins over a configured visualViewport.height (test-injection contract). 8 pre-existing tests stay green. dvh / svh browser support — Safari 15.4+, Chrome 108+, Firefox 101+ — comfortably covers iPhone 17 Safari and every supported desktop browser; no behavioural change on non-iOS. -
Print queue
require_previous_successno longer cascades indefinitely after a user-cancelled print (#1667, fully root-caused by @599w6c26tv-droid) — Reporter on an A1 saw a single user-cancelled print block 18 downstream queue items over 3 days, all markedskippedwithPrevious print failed or was aborted. They captured the override log line proving Bambuddy correctly detects the cancellation (Overriding status 'failed' -> 'cancelled' for printer 1 (print was stopped from queue by user)) but the scheduler's gate ignored the override; they dumped the affected DB rows confirming the cascade pattern; and they reproduced from clean state in one cycle. Two distinct bugs in one function (PrintScheduler._check_previous_successinservices/print_scheduler.py): (a) The lookback query.in_(["completed", "failed", "skipped", "aborted"])excludedcancelled, so a user cancellation was never found as the most-recent predecessor — the query walked past it to whatever real outcome existed before. (b) The same lookback INCLUDEDskipped, so once one item got skipped (under any reason — bug-cascaded or genuinely failure-gated) it became the next item's "failed predecessor" and the cascade compounded. Fix: swap the lookback list to["completed", "failed", "cancelled", "aborted"]and broaden the success check toprev_item.status in ("completed", "cancelled"). A user cancellation is a deliberate action — treating it as neutral matches the user's intent ("I'm done with that one, move on");skippedis excluded so the query always walks back to the most recent REAL print attempt andfailed/abortedstill gate as before. Conservative recovery migration: a one-shot pass incore/database.py::run_migrationsresets only the skipped items whose immediate real predecessor (bycompleted_atdesc, excluding the skipped-cascade itself) wascancelled— same fingerprint as the bug, narrow enough not to disturb skipped items whose true predecessor was a realfailed/abortedprint. Items match onstatus='skipped' AND error_message='Previous print failed or was aborted'and the predecessor check via correlated subquery; logged per-row at INFO so operators can audit the count after upgrade. Portable across SQLite and Postgres. Idempotent (post-reset rows no longer match). Tests: 10 new behaviour tests intest_check_previous_success.pypin every status/cascade combination — bug A (cancelled → True), bug B (skipped walked past), the reporter's exact failed→cancelled→skipped→skipped→pending cascade, regression guards on real failed / aborted still gating, edge cases (no-predecessor, only-skipped history, completed-then-failed). 7 new tests intest_cancellation_cascade_recovery_migration.pypin the migration — skipped-after-cancelled resets, skipped-after-failed stays, skipped-after-aborted stays, different-error-message untouched, reporter's multi-item cascade resets all, idempotent on re-run, per-printer isolation. All green; full scheduler + migration test suite stays green. -
Firmware-update check no longer 403s against Bambu Lab's Cloudflare-gated download page (#1666, reported by @arekm, with the working bypass demonstrated) — Reporter on a fresh install hit
Could not reach Bambu Lab's firmware download page...when checking firmware for an A1 Mini, and surfaced the diagnostic:curl -H 'User-Agent: Bambuddy/1.0' https://bambulab.com/en/support/firmware-download/allreturnsHTTP 403 cf-mitigated=challenge— Cloudflare upped the bot-protection onbambulab.comto a JA3 / TLS-fingerprint challenge. Plain Python TLS handshakes (httpx, requests, urllib) don't match Chrome's ClientHello bytes, so CF rejects before the request reaches the app layer. TheAccept/Accept-Languageheader workaround we shipped for #1350 was below-HTTP and no longer enough. Existing users with abuild_id.jsonon disk from a previous successful fetch kept working until Bambu rebuilt the page (every few weeks); fresh installs and wiped data dirs hit the wall immediately — exactly the reporter's path. Fix: usecurl_cffifor the twobambulab.comfetches only. New dependency added torequirements.txt;firmware_check.pylazy-initialises acurl_cffi.requests.AsyncSession(impersonate="chrome", ...)for thebambulab.comcalls (the index page that carries the Next.jsbuildId, and the per-model_next/data/{buildId}/.../{api_key}.jsonendpoint). Smoke-tested end-to-end against the live page: returns 200 OK + validbuildId, vs the reporter's 403. Compliance framing matters here: per the Bambu-compliance email from 2026-05-12, Bambuddy committed to "no falsified client identity."curl_cffi's Chrome impersonation only governs TLS handshake bytes — the HTTP-layer User-Agent is overridden back toBambuddy/1.0 (+https://github.com/maziggy/bambuddy)via the session'sheaders=parameter. Defensible read: TLS fingerprint matches Chrome (necessary because Python's TLS is the signal CF gates on), but every application-layer identity remains honestly Bambuddy. A new test (test_bambulab_curl_cffi_session_keeps_honest_user_agent) pins this — a future refactor that drops theheaders=override would silently revert to curl_cffi's Chrome-default UA and break the compliance commitment; the test fails on any non-Bambuddy UA in the session. Soft dependency: ifcurl_cffifails to import (rare platforms, alpine without wheels, etc.), the service logs a one-time warning at startup and falls back to httpx; wiki-based version detection continues to work for the badge, only the in-app firmware download URL stops resolving. New testtest_bambulab_get_falls_back_to_httpx_when_curl_cffi_missingpins the fallback path. The wiki path (wiki.bambulab.com) and the CDN download path (public-cdn.bblmw.com) stay on httpx — neither sits behind the same JA3 gate. Three existing tests (test_build_id_is_persisted_to_disk,test_build_id_falls_back_to_disk_on_403,test_download_page_unreachable_flag_set_on_403_json,test_download_page_retries_once_when_buildid_stale) updated to mock_bambulab_getinstead of the raw httpx client — a tighter mock target that's stable across the curl_cffi / httpx switch. -
Background asyncio tasks no longer get garbage-collected mid-flight (#1648 follow-up) — Support-bundle review under #1648 surfaced 94
Task was destroyed but it is pending!warnings in 8 days of v0.2.4.5. Root cause: asyncio holds only a weak reference to the result ofcreate_task— any "fire and forget" call site that doesn't store the returned task lets the event loop GC the task before it finishes. The warning gives no traceback, so the originating exception (if any) vanishes silently into a support bundle that looks scary but isn't actionable. Fix: newbackend/app/core/tasks.py::spawn_background_task(coro, *, name=None)helper that stores a strong reference in a module-level set, attaches a done-callback that auto-removes on completion AND surfaces any uncaught exception via the logger with the originating traceback, and accepts aname=argument so a leak source is traceable in/tracebacksand the log line. Migration: the 16 truly-orphanasyncio.create_task(...)call sites — acrossmain.py(8),printers.py,print_queue.py,firmware_update.py,archive.py,print_scheduler.py,library.py,smart_plugs.py,discovery.py,smart_plug_manager.py(3), andbackground_dispatch.py(2 lambda-wrapped) — switched tospawn_background_task. Othercreate_tasksites already kept strong refs viaself._tasks.append(...),self._x_task = ..., or localawait/gatherand stay unchanged. Tests: 5 unit cases intest_tasks.pypin the contract — strong-ref retention through completion, set-shrinkage after done, uncaught exception logged at WARNING withexc_info, cancellation does not log (a shutting-down service is not an error), and named tasks propagatename=. Net result: support bundles stop showing the opaque GC warnings, and any silent fire-and-forget exception now reaches the logger with a traceback attached. Severity reclassification of unrelated noise (the 791 "Failed to get cloud preset 400" spam, thebambu_cloud.Login failedmis-ERRORs, etc.) is a separate follow-up. -
Home-page filament assign no longer leaves the slicer unaware of PFCN cloud presets (#1648, reported by @ferch-G) — Reporter on an H2D with a Polymaker spool noticed that assigning the spool from the Dashboard left the slicer's filament dropdown showing "unknown" / generic, but clicking Configure right after made the slicer recognize it correctly — "Configure" felt like a mandatory follow-up step rather than a refinement. Root cause: PFCN-prefix cloud preset IDs were never handled. Bambu's cloud uses three preset-ID shapes:
GFS…(official Bambu),PFUS…(cloud user-created), andPFCN…(cloud shared / partner-uploaded — e.g. Polymaker's "(Custom)" Bambu Lab H2D variants like the reporter'sPFCN80e80c1f79db85).apply_spool_to_slot_via_mqttonly routedGFSandPFUSthrough the cloud-detail lookup that extracts the realfilament_id. PFCN slipped past the cloud-lookup branch, fell into the local-presetint()parse path, raised ValueError, dropped intonormalize_slicer_filamentwhich returns anyP-prefix unchanged, and the raw PFCN landed intray_info_idx— which the printer's calibration table can't index, so the slicer rendered "unknown". The Configure modal rescued each assign because it does its owngetCloudSettingDetaillookup and writes the resolvedfilament_id. Fix: extend the cloud-detail-lookup branch (inventory.py:129) and the discard safety net (inventory.py:223) to includePFCNalongsideGFS/PFUS. After the fix, the same three paths work: cloud-authenticated → realfilament_idfromdetail["filament_id"]ships astray_info_idx(Polymaker PLA Matte resolves toGFL05); cloud unavailable → raw PFCN discarded, slot reuses an existing valid P-prefix preset if material matches; nothing else available → falls through to the spool's generic material id (PLA → GFL99). Source comment now lists all three cloud-ID shapes so the next time Bambu invents a new prefix (PFXX, PFYY, …) the maintainer doesn't have to re-derive the structure from a bug report. Tests: 3 new integration cases intest_inventory_assign.py::TestAssignSpoolPfcnCloudPreset— falls back to generic when cloud unavailable (and pins the no-PFCN-leak invariant), reuses an existing slot's valid P-prefix preset when material matches, and the happy-path cloud lookup that produces a resolvedfilament_idwhile preserving the original PFCN assetting_id. Existing 28 assign-flow tests stay green. -
Bambu cloud A1 Mini filament / process profiles no longer hidden in AMS slot picker (#1649, root-caused by @technopaw) — Reporter on an A1 Mini observed that the AMS slot Configure dropdown showed no Bambu / Generic filament profiles; only user-authored profiles surfaced. Mirror in the Profiles tab: filtering by "A1 Mini" left only A1 (non-mini) results. Root cause: Bambu rolled out a profile rename mid-2026. The
@BBL <code>suffix on cloud profiles shifted from the long display form to a terse model code —Bambu PLA Basic @BBL A1 Mini ...is nowBambu PLA Basic @BBL A1M ...across 106 cloud profiles. User-authored profiles still use the long form (which is why the reporter's custom A1 Mini profile worked, and Bambu PLA Basic happened to render via thelocalPresetalways-shown path). Bambuddy's filter compared the extracted token verbatim against the display name ("A1M".toUpperCase() === "A1 MINI"is false), so the rename silently stripped every newly-renamed profile from the picker. Fix: centralized alias-aware match infrontend/src/utils/slicerPrinterMatch.ts. NewPRINTER_MODEL_SUFFIX_ALIASEStable holds the bidirectionalA1 Mini⇄A1Mmapping (uppercase-normalised, narrow on purpose — wide-net aliasing likeX1⇄X1Cwould silently group truly distinct printers); exportedmatchesPrinterModelSuffix(presetSuffix, printerModel)helper does the case-insensitive compare with alias fallback. Both consumer sites switched to the helper:ConfigureAmsSlotModal.tsx:586,607(the AMS slot picker, hit directly + reached from SpoolBuddy's AMS page viamapModelCode(printer?.model)), andslicerPrinterMatch.ts:classifyByBambuName(the SliceModal Process / Filament compatibility check). BackendPRINTER_MODEL_MAPalso gains aBambu Lab A1M→A1 Minientry so server-side 3MF printer-model normalization stays consistent if a future 3MF embeds the short form. The structure stays open: when Bambu introduces the next rename, it's a single new row in the alias table —/api/v1/cloud/settingsis the place to grep, called out in the source comment. Tests: 7 new unit cases inslicerPrinterMatch.test.tspin the alias helper (canonical, case-insensitive both directions, A1M ↔ A1 Mini in both orientations, A1M does NOT collapse to A1, A1 does NOT collapse to A1 Mini, unrelated models reject) plus 3 integration cases inpresetCompatibility(cloud filament@BBL A1Mmatches A1 Mini, cloud process@BBL A1Mmatches A1 Mini,@BBL A1Mdoes NOT match A1). 2 new component-level cases inConfigureAmsSlotModal.test.tsx:@BBL A1Mcloud preset surfaces when picker is for A1 Mini (with@BBL A1correctly filtered out), and@BBL X1Cstays filtered out when picker is for A1 Mini (sanity check against accidental widening). All existing 2062 vitest cases stay green. -
VP Queue / Archive / Review: Bambu Studio 2.7.x stayed stuck at "Downloading" after Send (#1658, reported by @IndividualGhost1905) — Reporter on Bambu Studio 2.7.1.57 + X1C reported that sending a model to a Queue-mode VP (with Auto-Dispatch off) left the slicer's send modal stuck at "Downloading" forever; clicking Delete on the queued item didn't release it, and even Auto-Dispatch ON + a successful real print didn't release it. Only toggling the VP off/on cleared the slicer. The deleted-from-queue framing is a red herring — the slicer was stuck before deletion, the user just noticed it most when they deleted. Root cause: the #1280 fix assumed the wrong event order. The original assumption was MQTT
project_file→ FTP upload → setgcode_state=FINISH, and the slicer's "Downloading" UI releases on FINISH. Bambu Studio 2.7.x flipped the Send sequence to FTPverify_job→ FTP.3mf→ MQTTproject_file, so on_file_received'sset_gcode_state("FINISH", …)fires first, then the synthetic_send_print_responseack runs and overwrites_gcode_stateback to"PREPARE". From that point the 1 Hz cached-as-base push stream carries PREPARE forever, the slicer waits for the FINISH transition it'll never see, and the modal sits stuck. Auto-Dispatch ON is the same bug: the real printer's gcode_state goes PREPARE → RUNNING → FINISH on its bridge, but_send_status_reportoverrides the cached push'sgcode_statewith the local_gcode_state(still PREPARE), so the real state changes never reach the slicer. The fix re-firesset_gcode_state("FINISH", filename, prepare_percent="100")fromon_print_command1.5 s after the synthetic ack, for every non-proxy mode (queue / archive / review). The 1.5 s window is long enough for the slicer's modal to see at least one PREPARE push on the 1 Hz cycle (so the transition reads as PREPARE → FINISH, matching what the slicer expects) and short enough that the modal feels responsive. Proxy mode is exempt — there the real printer drives the bridge state and a synthetic FINISH would clobber a real PREPARE/RUNNING transition. The scheduler cancels any in-flight timer when a new project_file lands so a slicer that retries doesn't end with two competing FINISH timers. Tests: 6 new cases intest_virtual_printer.py— schedules on archive (and by extension queue/review), proxy mode does NOT schedule, no-MQTT skip is silent, secondproject_filecancels the first timer, delayed run sets the expected(state, filename, prepare_percent)triple, empty filename does not schedule. -
Finish photo no longer shows the bed already dropped (#1397, reported by @rtadams89, @Jeff-GebhartCA, @MA2ZAK) — Bambu's end-gcode lowers the build plate as soon as the print completes. Bambuddy's existing finish-photo path captured a fresh camera frame at
gcode_state=FINISH, by which time the bed was already at the bottom of the chamber — the photo showed the top of the print well below the camera's natural framing, badly framed and sometimes invisible. Earlier capture attempts (atlayer_num >= total_layer_numwhile still RUNNING) hit motion-blur because the toolhead was still parking; capturing through the window kept the wrong frame because the latest was always ~2s before FINISH, mid-bed-drop. The fix sources the photo from a brief Bambu timelapse Bambuddy records on every dispatched print instead. Firmware stops timelapse recording AFTER the toolhead parks but BEFORE the bed-drop end-gcode runs, so the last frame frames the finished print correctly — verified on N=2 H2C prints by extracting the last frame of two real timelapses (spoolbuddy_v2.1andcase_SpoolBuddy); both showed the print clearly with the toolhead parked off-frame upper-left and the bed at print height, no motion blur. The post-park-pre-drop window is at least ~2 seconds wide on both, so-sseof -1.0(seek to last second, skip the literal last frame) is safe against any encoder tail artifact. Implementation: force-on at dispatch + cleanup after extraction.BackgroundDispatchService._resolve_effective_timelapse(db, archive, job)reads thecapture_finish_photosetting before eachstart_printcall (reprint + library-file flows both wired) and, when the user did NOT opt in to timelapse for this print, overridestimelapse=Trueon the MQTT command + marks the newPrintArchive.bambuddy_forced_timelapsecolumn True. User-opted-in timelapses pass through unchanged (no override needed). Migration adds the column branched onis_sqlite()for the boolean default (DEFAULT 0on SQLite,DEFAULT FALSEon Postgres — PG rejectsDEFAULT 0for BOOLEAN). New module-levelextract_video_last_frame(video_path, output_path)inservices/camera.pyruns a singleffmpeg -sseof -1.0 -i <video> -frames:v 1 -q:v 2 -update 1 <out>subprocess — no full transcode, ~150ms wall time on the dev box. Bounded 15s subprocess timeout that kills the child on hang. Returns False (never raises) on missing ffmpeg / missing video / non-zero exit / timeout._capture_finish_photo_from_timelapse(archive_id, archive_dir)pollsarchive.timelapse_pathevery 3s for up to 60s —_scan_for_timelapse_with_retriesruns in parallel and writes that field when the FTP download finishes. When it lands and the file exists on disk, extract the last frame asfinish_<timestamp>_<hex>.jpg._background_finish_photonow tries the timelapse path first whentimelapse_was_active=Trueand no external camera is configured; the existing external-camera / buffered-live-frame / fresh-RTSP-capture chain stays in place as the fallback. Post-extraction cleanup: whenarchive.bambuddy_forced_timelapseis True,_cleanup_forced_timelapse(archive_id, printer_id)runs after the extractor (regardless of success — the user never asked for a timelapse file and we shouldn't leave debris even if ffmpeg failed): deletes the locally-attached file, clearsarchive.timelapse_path, then walks the four scanner directories (/timelapse,/timelapse/video,/record,/recording) trying FTP DELE against the original filename. Best-effort, never raises — a printer that's offline at cleanup time means one orphaned file on the SD card, not a broken Bambuddy flow. Notification timing: the photo-task wait_for budget bumps from 45s to 75s when a timelapse was active so the notification carries the correct bed-up photo instead of falling through to the live-cam grab on slow Wi-Fi links; ~30s of added notification latency at worst is the honest tradeoff. Scope limitation, documented in the camera wiki: only covers prints dispatched THROUGH Bambuddy (queue, reprint, print-now from File Manager). Prints started directly on the printer's touchscreen, via the Bambu Handy app, or via Bambu Studio's "Send" function bypassbackground_dispatch.py, so the force-on doesn't fire and the live-cam fallback (bed-down) still applies for those. Can add the mid-printM981 S1 P20000MQTT toggle inon_print_startlater if anyone reports it for non-dispatched prints. External-camera users are unaffected throughout: their flow ignores the printer timelapse since external cams have their own framing and don't see one anyway. Verified on H2C only (core-XY); needs field verification on bed-slingers (A1, P1S) where the bed-drop kinematic geometry differs. Bed-slinger users invited to the test build to confirm. Setting description rewritten across all 11 locales to drop the "best quality when timelapse enabled" caveat (since Bambuddy now forces it) and call out the "kept-if-you-wanted-it, deleted-otherwise" behaviour. Tests: 6 intest_extract_video_last_frame.pycover the real ffmpeg happy path against a runtime-synthesised tiny MP4 (testsrc generator, ~3-5 KB, no committed binary fixture), missing source, empty source, ffmpeg-not-present (monkeypatched), nonzero-exit on garbage input, hung subprocess via patched-sleep-binary + tightened timeout. 4 intest_finish_photo_from_timelapse.pycover the polling helper with a patched-session fixture (no DB engine): timeout-without-landing, lands-and-extracts, lands-but-extraction-fails, file-materialises-mid-poll. Override + cleanup tests intest_dispatch_force_timelapse.pyandtest_cleanup_forced_timelapse.pypin: override fires only whencapture_finish_photoenabled AND user-timelapse off; user-opted-in timelapse passes through unchanged; cleanup deletes local + clearstimelapse_path+ DELEs remote when forced; cleanup skips when not forced. Round-2 fixes from field testing (Martin's H2D + X1C queue test): (a) the extractor's-sseof -1.0seek broke on small-print timelapses — Bambu records one frame per layer change, so a 16-layer cube produces a 0.625 s / 16-frame video and the 1-second seek-from-end went before the start of the file, ffmpeg silently returned frame 0 (empty bed at print start). Switched the extractor toffmpeg -i input.mp4 -update 1 -q:v 2 out.jpg— writes every decoded frame to the same output file (overwriting), so the file left on disk is the last frame regardless of duration. Verified on Martin's actual X1C-2 timelapse: produces the finished red cube with toolhead parked upper-left. Newtest_extracts_correctly_from_sub_second_videoregression test pins it against a 0.5 s synthetic MP4. (b) The dispatch-time override only wired intobackground_dispatch.py(Print Now / Reprint flows), but the print queue runs through a separate scheduler atprint_scheduler.py:_start_printwhich callsprinter_manager.start_printdirectly. Refactored the resolver to a module-levelresolve_effective_timelapse(db, archive, user_wanted_timelapse)function and wired it into the scheduler's call site too. Newtest_scheduler_force_timelapse_wiring.pywalks the scheduler's AST and asserts thestart_print(timelapse=...)kwarg referenceseffective_timelapse(notitem.timelapse) — guards against future refactors silently dropping the override on the queue path. -
Project edit modal couldn't be scrolled, so Save / Cancel were unreachable on short screens (#1642, reported by @klevin92) — Reporter on a Pi-class display (1508 × 831) couldn't mark a project as Completed because the edit modal's height exceeded the viewport and there was no way to scroll: outer wrapper was
fixed inset-0 flex items-center justify-center p-4(vertical-center) and the inner card had nomax-hand nooverflow. The top of the form went above the viewport and the bottom — including the Status dropdown the reporter was trying to use plus both action buttons — went below it. Workaround was a full page reload to drop the modal. Standard flex-modal-scroll fix:max-h-[calc(100vh-2rem)]+flex flex-colon the card (the2remaccounts for the outerp-4), aflex-1 overflow-y-auto min-h-0wrapper around the form fields, and the Cancel / Save buttons moved into aflex-shrink-0sibling with aborder-tseparator so they become a sticky footer that's always visible regardless of scroll position. The buttons stay inside the<form>sotype="submit"still works. 2 new vitest cases inProjectsPage.test.tsxpin the structural fix: the Save button is NOT a descendant of theoverflow-y-autoregion (otherwise it would scroll off again) and the modal card carries themax-h-[calc(100vh-2rem)]cap. Other modals in the codebase with the samefixed inset-0 flex items-center justify-center+max-w-mdshape almost certainly have the same latent bug — not refactored here, will tackle when reported. -
VP MQTT bridge
net.info[].iprewrite never armed when the printer was added by hostname/FQDN (#1429, root-caused by @Mape6, also hit @TrickShotMLG02) — Reporter on a flat 192.168.3.0/24 LAN had added a P1S to Bambuddy by its router-provided DNS namep1s.fritz.boxinstead of its IPv4. On 0.2.4+ that one detail kept Bambu Studio Send going to the real printer instead of the Bambuddy archive whenever the printer was powered on — exact same surface symptom #1429 was originally about, but a separate root cause from the bind-IP encoding work shipped on 2026-06-02. The defensiveNOT armedlogging (issue1429_vp_ip_leak) added in this release pinpointed it on the reporter's bundle:MQTT bridge IP encoding NOT armed: invalid IPv4 (target='p1s.fritz.box', vp='192.168.3.27'): invalid literal for int() with base 10: 'p1s'. The encoder_ip_to_uint32_le(and the host-interface pickerfind_interface_for_ip) both assume dotted-quad IPv4 and bail on anything else, soBambuMQTTClient.ip_addressbeing the configured FQDN string short-circuited the rewrite path andnet.info[*].ipkept leaking the real printer's IPv4. Switching the printer record to an IPv4 cleared the issue immediately for the reporter — that workaround confirms the diagnosis exactly. Why this didn't bite pre-0.2.4: the bridge didn't donet.info[].iprewriting at all before #1429 shipped, so FQDN-configured printers worked by accident — nothing was trying to parse the host as IPv4. Fix adds_resolve_target_to_ipv4(target)inmqtt_bridge.py: pass-through whentargetalready parses asipaddress.IPv4Address, otherwisesocket.getaddrinfo(target, None, family=socket.AF_INET)to filter to IPv4-only (thenet.info[*].ipfield is uint32 LE — there's no IPv6 representation that fits, so an AF_INET6 result must not slip through). ReturnsNoneon empty input and onOSErrorfrom getaddrinfo so transient DNS hiccups don't break the encoding permanently;_refresh_ip_encodingfalls back to the existingNOT armedthrottle which re-resolves on every 30s refresh tick (DHCP / DNS churn picks itself up). Both the_ip_to_uint32_le(target_ip)call AND the_resolve_host_interface_for_target(target_ip)call now receive the resolved IPv4, so the same fix covers the bind-address auto-resolve path used on default-config (0.0.0.0 bind) installs that don't have a dedicated VP bind IP. The configured FQDN is preserved into the armed log line asconfigured→resolved(target=p1s.fritz.box→192.168.3.153) so a bad-DNS regression stays legible indocker logswithout grepping back to the not-armed lines. The unresolvable-input not-armed reason is nowcould not resolve printer host '<input>' to IPv4 (invalid address and DNS lookup failed)— names the actual configured value, not justinvalid IPv4 (target=...), so future bundles distinguish "DNS gave us a v6 address" from "user typed garbage" without a guess. Tests: 5 new inTestHostnameResolution(pass-through for IPv4, empty/None → None, FQDN → resolved IPv4 with AF_INET filter asserted,OSError→ None, end-to-end FQDN-targeted bridge arms with the resolved IPv4 in_target_ip_uint32_leand theconfigured→resolvedshape in the armed log). The existingtest_invalid_ipv4_logs_value_errorrenamed totest_unresolvable_target_logs_reasonand now patchesgetaddrinfotoOSErrorso the test is hermetic; asserts the newcould not resolve printer host 'not.an.ip'message. 49 bridge tests pass; ruff clean. -
MakerWorld URL imports into a writable external folder wrote bytes to internal storage, not the NAS (#1645, reported and root-caused by @needo37) — Reporter linked a writable external SMB folder, selected it as the destination in the MakerWorld import dialog, the import succeeded, the file card appeared in the File Manager under the external folder's view — but
lson the NAS turned up nothing, and afindacross the whole NAS and the container for the original filename matched nothing either. The bytes had landed in Bambuddy's internal<DATA_DIR>/archive/library/files/<uuid>.3mfinstead of<external_path>/<filename>on the mount. Root cause was the byte-import save helpersave_3mf_bytes_to_libraryatbackend/app/api/routes/library.py:422: it acceptsfolder_idbut never loaded the folder or inspectedis_external/external_path, hardcoded the destination toget_library_files_dir() / <uuid><ext>, and left theLibraryFilerow withis_external=False. So the row'sfolder_idpointed at the external folder while its bytes +is_externalflag both said "managed/internal" — exact same class of bug as #1112 (which got fixed for the multipart-upload and move paths but never applied to the byte-import path). Compounded by the UUID-renamed on-disk copy: searching for the human-readable basename anywhere — NAS or container — never matches. Fix is a direct mirror of the multipart-upload path that's done this correctly since #1112: load the targetLibraryFolder(whenfolder_idis non-None), feed it to the existing_resolve_upload_destination(target_folder, filename)helper which already produces(dest, is_external)and enforces the 403-read-only / 400-unwritable-or-missing / 409-collision rejections, write the bytes to that destination (real filename for external, UUID for managed), and persist the row via_stored_file_path(dest, is_external)+is_external=is_external. The route-layer read-only guard atmakerworld.py:256-260is preserved — it returns the friendlier error before the upstream download burns bandwidth — and_resolve_upload_destination's identical check stays as defence-in-depth for any future caller that skips the route gate. Thumbnails continue to live under the managedget_library_thumbnails_dir()regardless of the 3MF's location, matching the upload path. Tests: 4 new inTestImport(writable external → bytes on mount +is_external=True+ absolute file_path persisted; read-only external → 403 at route, no download; missing external_path → 400; filename collision → 409 with the pre-existing file's bytes untouched). 21 existing makerworld tests + 72 library-route tests stay green. Ruff clean. -
X2D archives lose 3MF metadata because FTPS handshake fails on firmware 01.01.00.00 (#1638, reported by @vasmarfas) — Reporter's first archive entries from a brand-new X2D landed almost empty (only print time visible, no filament weight / layers / MakerWorld link / thumbnail), and Spoolman filament-usage tracking also went silent. The support bundle traces the symptom end-to-end: at print start
backend/app/main.py::on_print_starttries the usual FTP-download dance for the 3MF, every connect attempt to the printer fails with[SSL: WRONG_VERSION_NUMBER] wrong version number (_ssl.c:1032), and ~2 minutes laterCould not find 3MF file for print: /data/Metadata/plate_1.gcode→Created fallback archive N for <name> (no 3MF available). The fallback path writes the row withfile_path="",file_size=0,content_hash=NULL, and no layers / filament / model-link fields — exactly the "almost empty card" in the reporter's screenshot. Spoolman tracking and reprint-grouping also degrade from the same root cause: both depend on metadata pulled out of the 3MF byThreeMFParser. The proximate cause is the FTPS handshake: Python 3.13's defaultssl.create_default_context()negotiates TLS 1.3, and the X2D's implicit-FTPS server on port 990 rejects the ClientHello withWRONG_VERSION_NUMBER. This is the same shape of symptom as the P2S 01.02.00.00 FTPS bug from #1401 — handshake / data-channel breakage triggered by the move to Python 3.13's TLS-1.3 default — but the wire-level failure mode is different (P2S completes the handshake and truncates mid-stream with 426; X2D fails the handshake outright). Both are addressed via the per-model registry that #1401 established:backend/app/services/ftp_profiles.pygains anX2Dentry withcap_tls_v1_2=Trueplus aN6 → X2DSSDP alias, so the X2D'sImplicitFTP_TLSconnection caps the SSL context'smaximum_versionto TLS 1.2 and the ClientHello looks like the one the firmware accepted before the Python upgrade. Deliberately conservative — every other model stays on negotiated TLS 1.3, only X2D-tagged sessions flip. Honest caveat: this ships as a hypothesis-driven trial rather than a confirmed root-cause fix. The TLS-1.2 cap is the most likely cure given the symptom's family resemblance to #1401, butWRONG_VERSION_NUMBERcould equally describe the X2D switching to explicit FTPS (AUTH TLS on a plaintext greeting) or moving the FTPS service to a different port — both would need a different code path. The reporter has been asked to test this build; if the cap doesn't clear the error, the registry slot stays useful as a tuning anchor and the next round of diagnostics (openssl s_client -connect <ip>:990 -tls1_2from a network-adjacent host) will tell us which of (2)/(3) applies. Tests: 3 new intest_ftp_profiles.pymirroring the existing P2S coverage —X2Dresolves tocap_tls_v1_2=True,N6SSDP code aliases to the X2D profile, lowercasex2dstill hits the cap. Existing P2S + default + unknown-model + frozen-dataclass + non-capped-spot-check (X1C / H2D / P1S / A1) tests stay green. Verified: ruff clean; the integration test attest_cap_tls_v1_2_actually_applied_to_ssl_contextalready pins the profile→ImplicitFTP_TLS→ssl_context.maximum_versionwiring so this entry can't silently fail to apply. -
Label printing produced two identical PDFs per click (#1628) —
LabelTemplatePickerModal.tsx::openBlobInNewTabcalledwindow.open(url, '_blank', 'noopener,noreferrer')and treated anullreturn as "popup blocked → fall back to<a download>click." Per the WindowFeatures spec,noopenerdeliberately forceswindow.opento returnnulleven on success, so theif (!win)fallback fired on EVERY click. Path 1 (window.open) opened the blob tab — on Linux Chromium without an inline PDF viewer the OS saved a random-named copy (thezo70GhSL.pdf/f7w0OcDi.pdffiles in the reporter's screenshot). Path 2 (fallback) downloaded a second copy namedbambuddy-labels.pdf. Two identical PDFs per click. Fix: dropnoopener,noreferrer. The blob is same-origin (created viaURL.createObjectURLfrom our own fetch response), the destination is a passive PDF preview tab with no script context to abusewindow.opener, andnoreferreris a no-op for blob URLs. After removal,window.openreturns a real window reference on success →if (!win)only fires on genuine popup-block, single PDF per click. Existing 17 vitest cases inLabelTemplatePickerModal.test.tsxstill pass; the change is comment + one parameter. -
Scheduled local backup time is now interpreted as local time, not UTC (#1602 follow-up) — Pre-fix: the time-of-day picker in Settings → Scheduled Local Backups stored the value as
HH:MMand_calculate_next_runinbackend/app/services/local_backup.pyinterpreted it as UTC (datetime.now(timezone.utc).replace(hour=..., minute=...)), so a UTC+3 user who wanted a 21:00 local backup had to enter 18:00. The UI hinted at this with a literal "UTC" label, but it was still surprising. Post-fix: the picker is interpreted in the container's local timezone, resolved from theTZenv var viazoneinfo.ZoneInfo(same source the Support page'senvironment.timezonealready shows). UTC fallback whenTZis unset or unrecognised — preserves the legacy behaviour rather than crashing. The UI now shows the resolved zone name next to the time field (Local time (Europe/Berlin)/Yerel saat (Europe/Istanbul)/ etc.) via a new i18n keybackup.localTimeHintwith real translations across all 10 non-English locales, replacing the oldbackup.utcliteral. Newtimezonefield on/api/local-backup/statusexposes the resolved zone to the UI. One-time behaviour change for existing users: anyone who entered a UTC time as a workaround (per #1602's UTC+3 reporter — "I have to write 18:00 to get 21:00 local") will see the first scheduled cycle after upgrade run at their local TZ offset earlier than expected. Re-enter the time as local once and it's correct from then on. No migration is shipped; the population is small and migrating around a DST boundary would be ambiguous. Tests (backend/tests/unit/test_local_backup.py): existing 5 cases pinned withmonkeypatch.setenv("TZ", "UTC")so they don't depend on the test runner's TZ; 5 new — Europe/Berlin local→UTC, Europe/Istanbul (the #1602 reporter's zone) local→UTC, no-TZ-env UTC fallback, unrecognised-TZ UTC fallback, DST spring-forward gap (Europe/Berlin 2026-03-29 02:30 wall-clock doesn't exist) asserting no crash. All 30 tests pass. Frontend i18n parity green at 5007 keys across 10 non-English locales. -
Auto-print end snippets silently dropped on P1S, and modified 3MFs rejected with HMS 0500-4003 (#1516, contributed by @phieb) — Two compounding firmware quirks made the original #422 G-code injection unusable on the P1S — the most common reporter platform for auto-eject / plate-clear automation. (1) End snippet dropped after
; EXECUTABLE_BLOCK_END: the snippet was appended to the end ofMetadata/plate_N.gcode, but Bambu firmware (verified on a P1S) does not execute G-code that sits after the; EXECUTABLE_BLOCK_ENDmarker. An auto-eject sweep injected to clear the plate ran on every other Bambu model but silently no-op'd on P1S — print finished, plate stayed loaded, the next queued copy stalled behind it. The injection now anchors the end snippet before; EXECUTABLE_BLOCK_ENDso it sits inside the executed block, after the printer's own machine-end sequence (cooldown / M104 S0 / etc) but before the firmware stops parsing. Files without the marker — older slicer versions, non-Bambu sources — keep the existing append-to-EOF behaviour with a warning log; the test suite pins both paths. (2) Stale.gcode.md5sidecar rejected by P1S firmware: every plate carries aMetadata/plate_N.gcode.md5sidecar that the P1S validates against the gcode body on load. Rewriting the gcode without refreshing the hash made the P1S reject the file withHMS 0500-4003 "unable to parse"and abort the print — exactly when the injection had succeeded.inject_gcode_into_3mfnow recomputes the sidecar from the exact bytes about to be written and re-packs it into the 3MF in the same pass, matching Bambu's on-disk format exactly (uppercase hex, 32 chars, no trailing newline). The MD5 is a firmware integrity check, not a security primitive, so the call is flaggedhashlib.md5(..., usedforsecurity=False)to keep ruff S324 / Bandit B324 clean. 3MFs without an.md5sidecar member (older files, manual hand-builds) do not gain one — inventing a member could surprise older firmware that doesn't expect it; if the source had no sidecar the firmware wasn't validating it anyway. Non-target zip members keep their original compression intact (the P1S preview parser chokes on re-DEFLATEd PNGs that the source had stored uncompressed). Live-tested on a P1S with{max_layer_z}placeholder substitution: injection happens, the file loads cleanly, the end snippet executes, the recomputed hash validates against the modified body. Tests (test_gcode_injection.py): 4 new cases inTestMd5SidecarRecompute(sidecar matches the exact gcode bytes after injection, uppercase-hex-no-newline format parity with Bambu's on-disk shape, no.md5member is invented when source lacks one, non-target zip member compression is preserved) + 1 new casetest_end_lands_before_executable_block_endpinning the in-block placement when the marker is present + 1 case renamed fromtest_end_still_appended_at_eoftotest_end_falls_back_to_eof_without_block_markerto reflect that EOF append is now the fallback path, not the primary one. 198 VP + gcode_injection tests in the slice green; full backend 6167/6167; ruff clean. -
Reprint with quantity > 1 + auto-print G-code injection now injects every copy, including the first (#1516, contributed by @phieb) — since auto-print injection landed (#422), ticking Inject auto-print G-code in the Reprint dialog only applied to copies 2…N: the first copy was dispatched immediately via the direct reprint path, which bypasses the scheduler that performs injection, so it printed without the start/end snippets. For auto-eject / plate-clear setups (Farmloop, SwapMod, AutoClear, Printflow 3D) this left the first copy stuck on the plate, blocking the injected copies queued behind it. When injection is enabled and quantity > 1, the Reprint flow now queues all copies so each one is dispatched — and injected — by the scheduler. Behaviour with injection off is unchanged (first copy still prints immediately, the rest queue), as are the single-copy reprint, Add-to-Queue, Edit-queue-item, and stagger paths.
[0.2.4.5] - 2026-06-03
Added
- System theme detection — sidebar toggle and Settings selector follow OS dark/light preference (#1418, contributed by @TempleClause via PR #1501) —
ThemeModegains a third value'system'alongside the existing'dark'/'light'. The provider listens towindow.matchMedia('(prefers-color-scheme: dark)'), tracks the OS preference in real time, and exposes a newresolvedMode: 'light' | 'dark'to consumers — the actual rendered theme after resolving system → OS preference. Layout's sidebar toggle now cyclesdark → light → system → darkwith the icon hinting at the next stop (Sun→Monitor→Moon); the existing logo selection and the dark/light "active" panel highlight in Settings switched frommodetoresolvedModeso they always reflect what's actually painted, regardless of whether the user chose explicitly or inherited from the OS. Settings → Appearance gained a 3-button Dark / Light / System selector (border-green-keys-off-modeso System actually highlights System even when it resolves to dark), with a "Settings saved" toast on click matching the adjacent Background/Accent/Style selects. Existing users' persistedtheme-modeis untouched — anyone ondarkorlightstays there and simply gains an extra stop in the cycle; new installs default todark. Review-caught fixes shipped in the same PR: (a) the project's__tests__/setup.tsmockedwindow.matchMediawithvi.fn().mockImplementation(...), whichvi.restoreAllMocks()in three test files reset to "return undefined" — pre-PR nothing calledmatchMediaat render time so the wipe went unnoticed, this PR was the first caller and broke 23 existing tests. Rewritten as a plain function (Object.defineProperty(window, 'matchMedia', { writable: true, value: (query) => ({...}) })) sorestoreAllMockscan't touch it. (b)themeToggleHinthad previously only been updated inen.ts; real translations now ship in all 8 non-English locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW) describing the 3-state cycle without referencing the old sun/moon icon pair. (c) PR description reworded to honestly call out the sidebar cycle change as a behaviour change for every user of the toggle (dark → light → systemnow intercepts where users previously gotdark → light → dark), with the persisted-preference-unchanged caveat made explicit. (d) New i18n keynav.switchToSystemwith real translations across all 9 locales ('Switch to system mode'/'Zum Systemmodus wechseln'/'システムモードに切替'etc.). Tests: 11 new inThemeContext.test.tsx(systemPreference inits frommatchMedia.matches, change event updates state, resolvedMode follows explicit mode vs systemPreference permodevalue, dark class applied based on resolved mode,toggleModecycles dark→light→system→dark); 1 new inLayout.test.tsx(toggle button title attribute walks the cycle); 4 new inSettingsPage.test.tsx(all three buttons render, active green border keys offmode, click switches mode, click fires toast). 26 previously-broken tests inAddNotificationModal.test.tsx+NotificationProviderCardStockAlerts.test.tsx+CameraTokensPage.test.tsxpass again post-setup.tsfix. Frontend build clean (2682 modules); i18n parity green at 4995 keys × 9 locales (+1 fromswitchToSystem). Contributor handled the entire round-1 review (matchMedia mock, locale parity, PR honesty, full test coverage, toast parity,.map()refactor for the button group) in a single revision push, no follow-ups deferred. - MQTT auth rate-limit on the virtual printer — Bambuddy's VP exposes an 8-char access code via the slicer-facing MQTT server on port 8883. Without a rate limit the code is brute-forceable by anyone who can reach the VP's bind IP (LAN, Tailscale, or any other tunnel the user chose to expose). The new per-IP limiter records each failed CONNECT auth attempt and rejects further CONNECTs from that IP once 5 failures occur within a 60 s window. The window is sliding (not cumulative), recovers automatically after expiry — no manual unblock — and successful auth clears the IP's prior failure history so a user who fat-fingered their code 3 times then got it right isn't penalised on their next reconnect. Per-IP tracker uses
time.monotonic()so wall-clock jumps can't extend or shorten the window unexpectedly. Constants_AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5and_AUTH_RATE_LIMIT_WINDOW_SECONDS = 60.0are module-level for ops tunability. 5 unit tests intest_vp_mqtt_server.py::TestAuthRateLimitpin the under-limit/at-limit/window-recovery/multi-IP/success-clears semantics. - Per-slicer MQTT response routing for multi-slicer VP setups — Pre-fix: when slicer A sent
extrusion_cali_get(or any other bridge-forwarded command) to a non-proxy VP bound to a target printer, the printer's response was fanned out to every connected slicer — leaking slicer A's response into slicer B's command stream. Slicers ignore responses to sequence_ids they didn't send, but the leak is still wrong and could confuse multi-slicer-host setups (workstation + laptop both connected to the same VP). The fix recordssequence_id → originating client_idinSimpleMQTTServer._pending_requestson the way out and looks it back up inpush_raw_to_clientson the way in, routing the response only to that one client. Falls back to broadcast for printer-initiated unsolicited pushes (push_status etc. — every slicer expects these) and for sequence_ids the map never saw recorded (covers slicers subscribing mid-flight). Bounded at 256 entries with FIFO eviction so a slicer that sends commands without ever consuming responses can't leak memory. 6 unit tests intest_vp_mqtt_server.py::TestPendingRequestRoutingcover seq-id capture across nested blocks, lookup-pops-entry semantics, FIFO eviction at cap, malformed-payload fallback, and broadcast on unrecorded seq. - H2D Pro virtual-printer support (experimental — needs field confirmation) — Added SSDP model codes
O1EandO2DtoVIRTUAL_PRINTER_MODELSand matching09400Aserial prefixes toMODEL_SERIAL_PREFIXESso the H2D Pro shows up in the Add Virtual Printer model dropdown and advertises a model code distinct from H2D'sO1D. The codes were transcribed from the project's model-codes reference but have not been validated against a live H2D Pro's SSDP response. Anyone with an H2D Pro who picks this from the dropdown should confirm BambuStudio recognises the VP correctly; if not, the code values need a one-line correction and a follow-up release. - VP child-service readiness barrier — Pre-fix:
VirtualPrinterInstance.start_serverspawned each child sub-service (FTP, MQTT, Bind, SSDP) as aasyncio.create_taskand returned immediately.is_runningthen reportedTrueeven though the child sub-services' sockets were still in the gap betweenasyncio.create_task(...)and the innerasyncio.start_serverreturning. A caller racing the start (the diagnostic route, the VP-card UI poll, an integration test) could seerunning=passwhileport_ftps=fail. Each child now exposes aready: asyncio.Eventthat's set after the actual socket bind, andstart_serverawaits all of them with a bounded 5 s timeout. If a child hangs binding, the timeout logs aSub-service didn't bind within 5s: ...warning and the VP continues — the existing task-tracking still catches the failure on the next iteration. The 5 s ceiling is well above any legitimate bind on healthy hardware; on a Pi 3 with a congested SD card it's tight but bounded.
Changed
- Bug-report template: tightened fields + new Area dropdown to cut invalid-issue triage load — 170 issues have been closed with the
invalidlabel (61 of them in the last 30 days alone — roughly 1 in 5 of all closed issues), nearly always because the reporter hadn't run the in-app diagnostics or checked the documented troubleshooting page. The template now forces engagement with the tools that were already shipped. Form changes (bug_report.yml): (a) the "I ran the Connection Diagnostic" checkbox flipped fromrequired: falsetorequired: true, so the form blocks submission until the reporter has actually used the diagnostic (or knowingly lied — higher friction than reading the doc); (b) the Support Package textarea is nowrequired: trueinstead of optional, with the field's prompt rewritten to "Drag the .zip here, or explain why you cannot attach one" so users without a working Bambuddy still have a path; (c) a new required "Troubleshooting steps already taken" textarea sits between Steps to Reproduce and the printer-model dropdown, asking which wiki pages were checked and which in-app diagnostics were run — empty answers can't submit, which produces either real evidence or an admission that nothing was tried (both of which are useful for triage); (d) the pre-form markdown intro now spells out the "search → wiki → diagnostic → support package" sequence with a citation of the 1-in-5 stat so reporters understand the why before they reach the fields; (e) the final-checks list grew from one to three required confirmations (searched issues + checked troubleshooting wiki + ran Connection Diagnostic for connection/printing/camera bugs), with the wiki-checked confirmation linking to the rendered troubleshooting page. Bug categorization (the gap that motivated the rewrite): the old singleComponentdropdown only carriedBambuddy / SpoolBuddy / Both— useless for area triage. Replaced with TWO required dropdowns:Product(Bambuddy / SpoolBuddy) andArea(15 options covering the actual feature surface — connection, dispatch, filament/AMS, slicer, VP, camera, archives, stats, queue, notifications, auth, updates, UI, integrations, SpoolBuddy kiosk, plus an Other escape hatch). Auto-labeling (.github/workflows/auto-label-area.yml): on every issue open/edit, anactions/github-script@v7step parses the Area dropdown out of the rendered issue body (matching the### Area\n\nValueblock GitHub forms produce) and applies the matchingarea:*label. Tolerant of CRLF, the_No response_placeholder, and the issue-edit re-fire path (won't re-add an already-present label). Unrecognised Area values emit acore.warningso missed sync between the form and the workflow map shows up in Actions logs. Maintainer hand-off: 15area:*labels need to be created once viagh label create(see commit message for the exact commands) — labels referenced by the workflow but missing in the repo cause theaddLabelscall to throw, so this prerequisite is load-bearing. Printer Model dropdown verified againstPRINTER_MODEL_MAPinbackend/app/utils/printer_models.py— all 13 current Bambu models present (X1 Carbon / X1 / X1E / X2D / P1S / P1P / P2S / A1 / A1 Mini / H2D / H2D Pro / H2C / H2S), no update needed. YAML syntax validated via Pythonyaml.safe_loadfor both the template and the workflow. - VP virtual-printer FTP server: cmd_STOR streams chunks straight to disk instead of buffering the whole upload in memory — Pre-fix:
cmd_STORaccumulated every chunk in alist[bytes]and calledwrite_bytesat the end. Peak RSS for a multi-GB.gcode.3mf(multi-plate dense prints) was ~2× the file size — chunks held + theb''.joinof them — and could OOM-kill a low-memory host (Pi 3, low-end Synology, etc.). The streaming rewrite writes each 64 KiB chunk tofile_path.open("wb")inline as it arrives, bounding peak memory at one chunk regardless of total upload size. Wire protocol unchanged — same150 → 226sequence, same destination path, no new verbs, no concurrency guard. The visible difference is that the destination file grows progressively rather than appearing all-at-once on completion; slicers don'tLISTduringSTORso this isn't observable. Same change adds aMAX_UPLOAD_BYTES = 4 GiBhard cap — a runaway or malicious client can no longer drive RSS or disk to exhaustion. On the cap path the partial file is unlinked so a slicer retry starts clean. 4 unit tests intest_vp_ftp_stor.py(happy-path bytes on disk + 226, cap-violation 426 + partial cleanup, mid-stream read error cleanup, MAX_UPLOAD_BYTES sanity floor). - VP virtual-printer FTP passive port range widened from 50000-50100 (101 ports) to 50000-51000 (1001 ports) — The original range was sized for a single VP. With multiple VPs each running their own FTP server, concurrent passive data connections compete for the 101-port pool and the bind-retry loop's 10 random picks can collide; 1001 ports gives headroom. Only affects the non-proxy path (
VirtualPrinterFTPServer.PASSIVE_PORT_MIN/MAX). The proxy path'sSlicerProxyManager.FTP_DATA_PORT_MIN/MAXstays at 50000-50100 because it pre-binds the printer-side range exactly. Docker bridge-mode users mapping the old range need to update to50000-51000:50000-51000—docker-compose.yml,install/docker-install.ps1warning, and the wiki (docs/getting-started/docker.md,docs/features/virtual-printer.md— port table, two UFW rules, two firewalld rules, Cloudflare-tunnel list, firewall troubleshooting line) all updated with "widened in 0.2.5" notes. Docker host-mode and bare-metal users are unaffected (no port mapping involved). The proxy-mode FTP-data row in the wiki stays at 50000-50100 because that path is unchanged. - VP MQTT bridge sticky-keys: 7 more fields preserved across incremental pushes — Pre-fix: when the bridge cached a real printer's
push_status, the very next 1 Hz incremental push (which only carries changed temps / fan / wifi_signal) wiped any field not in the sticky-keys allowlist. The cached state lostupgrade_state,xcam,hw_switch_state,nozzle_diameter,nozzle_type,onlineandams_statusafter a single tick — BambuStudio's Send pre-flight reads several of these (upgrade_state.dis_state/force_upgradein particular) and could refuse Send because the cached push said "unknown firmware state". Same shape as #1228 (storage indicators) and #1558 (live-progress fields) — the cached-branch field-shape parity, not a new mechanism. Sticky-keys carry-forward is now also acopy.deepcopy(was reference) so a future merge that mutates a carried-forward dict in place can't corrupt both copies. - VP target-printer DHCP IP / serial refresh now restarts proxy VPs — Pre-fix: when a target printer's IP changed (DHCP renewal, network reconfiguration), the running proxy VP kept forwarding to the stale IP forever because
sync_from_db's "changed" predicate didn't compareproxy_ipsagainst the running instance'starget_printer_ip/target_printer_serial. The user had to manually toggle the VP to refresh. Nowsync_from_dbre-evaluates the proxy target each cycle and restarts the VP when the IP or serial actually changes — same code path as a config change. If the target printer's DHCP lease cycles frequently this means more proxy restarts, but the alternative was silent breakage; documented in the release-notes for users on flaky-DHCP networks. - VP queue_force_color_match setting takes effect immediately — Pre-fix: toggling the per-VP
Force exact color matchsetting via the UI silently no-op'd becausesync_from_db's "changed" predicate didn't include the field. The user had to restart the process for the new value to land. The predicate now also checksqueue_force_color_matchso the running instance gets restarted on toggle. - VP MQTT client session errors elevated from DEBUG to WARNING — The outer
except ExceptioninSimpleMQTTServer._handle_clientwas logging at DEBUG, which production deployments default to suppressing. Users reporting "slicer disconnects randomly" then had no signal to pass us. WARNING surfaces it. Inner handlers' expected parser/IO failures stay at DEBUG — only unexpected errors that would otherwise reach the outer catch get visibility. - VP MQTT periodic status push now logs a one-line per-minute counter per active slicer connection (#1548 follow-up) —
_periodic_status_pushemits1Hz status push: N pushes/min to <client>at INFO level once per minute per connected slicer (silent when no slicer is attached). The 1 Hz status push was previously silent at INFO; when a reporter sent a support bundle showing an idle disconnect, there was no way to tell whether the push task was actually pushing to that connection or being eaten silently. The counter both confirms the task is healthy for a given client and gives us a concrete data point (N < 60 means pushes were dropped) when triaging future "slicer disconnects on idle" reports. No behaviour change to the push itself.
Security
- PyJWT bumped to >=2.13.0 to pick up upstream advisory fixes —
pip-auditflagged four advisories against 2.12.1 (all fixed in 2.13.0). Pre-bump audit confirmed Bambuddy's usage is unaffected by the five behavioural changes in 2.13.0: (a) HMAC empty-key reject —_get_jwt_secret()already guards against""at every priority (env-var falsy check, filelen >= 32gate, generatedsecrets.token_urlsafe(64)); (b) PyJWK header-algmust match JWK's algorithm — OIDC decode inmfa.py:1846usessigning_key.key(raw-key path), not thePyJWKwrapper, so this branch doesn't apply; (c)PyJWKClientrejects non-HTTP(S) URIs at construction —mfa.py:1839constructs from OIDC discoveryjwks_uriwhich is HTTPS, andfetch_datais overridden so the URI is never fetched anyway; (d)b64=falseRFC 7515/7797 strictness — no detached-payload usage anywhere in the codebase; (e) per-callenforce_minimum_key_lengthnow actually enforces — option not passed anywhere, and the generated 64-byte secret is well over HS256's 32-byte minimum regardless. 229 auth/MFA/OIDC integration tests + 78 auth-related unit tests pass on 2.13.0; runtime encode/decode roundtrip with the realSECRET_KEYverified;pip-audit --strictreports no remaining vulnerabilities. Pins bumped inrequirements.txt(PyJWT>=2.13.0) andpyproject.tomldev group (pyjwt>=2.13.0). - **WebSocket auth gate + audit-driven hardening sweep — A proactive auth-surface audit run surfaced one critical (
/api/v1/wsbroadcast every printer-status / archive / inventory event to anyone reachable on the HTTP port. All fixed in the same PR. - API-key permission enforcement is allowlist-based (reported by @vfxdev) — The three documented API-key scopes ("Read Status", "Manage Queue", "Control Printer") were enforced only inside the legacy
/api/v1/webhook/*router; every other route usedrequire_permission_if_auth_enabledwhich fell through to a 17-entry admin denylist for API keys and ignored the per-key scope flags. The structural failure modes: (a) any valid key, including one with every scope checkbox unticked, could call print start/stop/pause/resume, queue create/delete/reorder, archive reprint, and every*_READendpoint outside the denylist; (b)require_any_permission_if_auth_enabled(inventory.py) andrequire_ownership_permission(print_queue.py,archives.py,library.py,library_trash.py) returnedNonefor any valid key with zero scope check, granting full ownership-modify access to ~10 ownership-gated routes; (c) every newPermissionenum value added tocore/permissions.pysince the denylist was written silently joined the "API-key-allowed" bucket — fail-open-by-construction, which is exactly how the surface grew over time. Fix:core/auth.py::_check_apikey_permissionsnow consumes a new_APIKEY_SCOPE_BY_PERMISSIONallowlist that maps every non-adminPermissionto exactly one scope flag on theAPIKeyrow; unmapped permissions return 403 ("administrative operations") regardless of which flags are set; the helper is now invoked in all three previously-skipping dependencies. The denylist is retained as a redundant explicit "these are admin" marker plus drift-detection in tests, but the allowlist is the load-bearing check. Two new scope flags (per same-PR design discussion):can_manage_library(gatesLIBRARY_UPLOAD/LIBRARY_UPDATE_OWN/LIBRARY_DELETE_OWN/MAKERWORLD_IMPORT— distinct trust level from queue management; rejected the "fold library upload into can_queue" shortcut) andcan_manage_inventory(gatesINVENTORY_CREATE/INVENTORY_UPDATE/INVENTORY_DELETE/INVENTORY_FORECAST_WRITE— required because SpoolBuddy kiosks write NFC scans, scale readings, and/spoolbuddy/devices/{id}/system/command+/updatevia INVENTORY_UPDATE under the prior denylist gap; 15+ kiosk routes depend on this scope).CLOUD_AUTHis now routed through the existingcan_access_cloudflag (was unmapped → would have admin-denied; the router-level_cloud_api_key_gatealready does this check, but the route-level dep now fails closed too for defence in depth). Migration (core/database.py::run_migrations, dialect-branched per feedback_sqlite_and_postgres_upfront): two new boolean columns added toapi_keyswithDEFAULT TRUE, one-shot backfilled to mirrorcan_queue(gated on a new_api_keys_column_existscheck so the backfill runs only on the migration that adds the column — user-edited values on subsequent restarts are never clobbered). Backfill rationale: a key the operator created as "queue-only" was implicitly relying on the upload+queue and inventory-write workflows the queue scope already let through, so mirroringcan_queuepreserves the operator's intent; a hardened "read-only" key (can_queue=False) does NOT silently gain new writes on upgrade. The bundled SpoolBuddy CLI key is explicitly grantedcan_manage_inventory=Truebecause the kiosk itself is the legitimate writer (NFC scan, scale reading, /system/command). Structural drift backstop: newtest_every_permission_has_a_classificationfails CI on any futurePermissionadded tocore/permissions.pywithout an entry in_APIKEY_SCOPE_BY_PERMISSIONor_APIKEY_DENIED_PERMISSIONS— the previous denylist shape allowed silent surface growth, this catches it. Tests (test_auth_apikey_rbac.py): 78 new — pure-logic_check_apikey_permissionsmatrix covers every (Permission × scope-flag combo) outcome with cross-scope leakage assertions, the structural drift-detection guard, allowlist/denylist disjointness, scope-flag-has-permissions sanity, unknown-perm-string + empty-perm-list fail-closed cases, and therequire_any=Truesemantics; the existing denylist-integrity test is updated to reflect that INVENTORY_CREATE/UPDATE are now allowlisted (not admin-only-by-omission) and that operations admin only via omission (PRINTERS_CREATE, LIBRARY_DELETE_ALL, LIBRARY_PURGE, DISCOVERY_SCAN) still 403 with a fully-flagged key. Full 5469-test backend suite green; backend ruff clean. Frontend: API-key create dialog gains "Manage Library" + "Manage Inventory" checkboxes with descriptions, the existing list view gains Library and Inventory badges, the cosmeticapiKeyName/save-toast flow is unchanged;api/client.tsAPIKey/APIKeyCreate/APIKeyUpdatetypes extended. i18n parity: real translations for the 6 new keys (manageLibrary/manageLibraryDescription/manageInventory/manageInventoryDescription/libraryBadge/inventoryBadge) across all 9 locales per the feedback_translate_dont_fallback HARD RULE; parity script green at 5005 leaves × 9 locales. Wiki (features/api-keys.md): permissions table grows from 5 to 7 toggles with the new scopes and an updated "Principle of Least Privilege" examples list; upgrade notes call out the can_queue-mirroring backfill so operators understand why an existing "queue-only" key keeps uploading after upgrade (and why a "read-only" key still won't); a new explicit "Allowlist model since 0.2.4.5 (GHSA-r2qv-8222-hqg3)" callout documents the shift from denylist to allowlist with the exact previous failure mode (so the audit-trail isn't only in this CHANGELOG). Out of scope / explicit choice: did not refactor the SpoolBuddy kiosk routes to use a more semantically-accurate permission thanINVENTORY_UPDATEfor/system/commandand/update(large blast radius across 15+ route decorators, andcan_manage_inventorymatches the trust dimension correctly); did not consolidate the bespokerequire_energy_cost_updateinto the new allowlist (its narrow-scope semantics — bypass the SETTINGS_UPDATE denylist viacan_update_energy_cost— predates this work and is still the right shape for that one electricity-price endpoint). - Trivy DS-0026 (
Dockerfile.testmissing HEALTHCHECK): silenced viaHEALTHCHECK NONE— The test image runspytestand exits; there is no long-running service to probe, so any HEALTHCHECK we added would be cargo-cult noise.HEALTHCHECK NONEis the documented Docker directive to explicitly opt out of any inherited healthcheck and is the way Trivy expects projects to signal "this image is not a service." Closes code-scanning alert #813. - VP access codes now compared with
hmac.compare_digest(constant-time) — Pre-fix: bothFTPSession.cmd_PASSandSimpleMQTTServer._handle_connectused Python's==operator on the 8-char access code. Constant-time comparison closes the timing-side-channel without changing the protocol surface. Same auth, no UX change. - VP MQTT brute-force rate-limit per source IP — 5 failed CONNECT attempts within a 60 s sliding window block further auth attempts from that IP for the rest of the window. Auto-recovers — no manual unblock. Constants
_AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5/_AUTH_RATE_LIMIT_WINDOW_SECONDS = 60.0are module-level for ops tunability. See Added section for full description. - VP
access_codeno longer leaked in DEBUG logs — Pre-fix:PUT /virtual-printers/{id}loggedbody.model_dump(exclude_unset=True)at DEBUG, which dumped the plaintext access code whenever the user saved a new one. Now the field is redacted (***) before the log emission. Violation surfaced by no-secrets-in-logs audit; not exploitable in the field (DEBUG is off by default) but is exactly the kind of leak the rule exists to prevent. - VP FTP upload capped at 4 GiB (DoS guard) —
cmd_STORnow rejects an upload that crossesMAX_UPLOAD_BYTES = 4 GiB, deletes the partial file, and replies 426. Without the cap a runaway or malicious client could drive RSS or disk to exhaustion; 4 GiB is well above any realistic multi-plate.gcode.3mf. Same code path adds the streaming rewrite (see Changed section for details). - Path-traversal hardening across the upload / import / file-write surface (routes + services); fifth CI backstop ships alongside — A private path-traversal report against
POST /api/v1/projects/import/filetraced two attacker-controlled strings being joined tolibrary_dirwith no resolve + containment check: (a)linked_folders[*].namefrom the request'sproject.json("Vector A" — an absolute path in this field collapsedlibrary_dir / "/anywhere"toPath("/anywhere")because pathlib discards the left side when the right is absolute, letting the nextwrite_bytesland anywhere the backend could write), and (b) per-entryzf.namelist()paths from the ZIP itself ("Vector B" — ZIP filenames carry..segments by spec and the joinlibrary_dir / folder_name / relative_pathhad no per-component check). Concrete escalation: drop a.pthfile into the venv'ssite-packagesdirectory for code execution on next service restart; overwrite the JWT signing-secret file to forge an admin token; overwrite~/.ssh/authorized_keysor~/.bashrcon native installs. Fix is structural, not just patch the diff (per feedback_dont_dismiss_preexisting). Newbackend/app/utils/safe_path.py::safe_join_under(parent, *parts)helper joins under a trusted parent, resolves both sides, assertsis_relative_to(parent.resolve()), and rejects up-front empty / null-byte / absolute path components. Wired intoimport_project_fileat both vectors. Adjacent fix from the routes audit:GET /api/v1/archives/{id}/photos/{filename}had NO validation onfilenameand FileResponse-served arbitrary paths — the existing DELETE endpoint at least had a membership check againstarchive.photos(which is UUID-generated on upload), but GET shared neither the check nor any traversal guard. Both GET and DELETE now route throughsafe_join_underfor defence-in-depth on top of the membership check. Second adjacent fix from the services audit:ArchiveService.attach_timelapse(archive_id, data, filename)inbackend/app/services/archive.py:1456wrotearchive_dir / filenamewherefilenameultimately comes from either a printer's FTP listing (compromised-printer threat model — the printer is part of the trust surface) or the?filename=...query param onPOST /api/v1/archives/{id}/timelapse/select. A malicious printer that returns a directory listing entry with..segments could write the timelapse bytes outside the archive directory; thef.get("name") == filenamegate in the route did not prevent it because the gate is satisfied by whatever the printer claims is on disk.attach_timelapsenow routes throughsafe_join_under(..., http=False)and returnsFalse(logging the rejection) when the join would escape — matching the existing not-found contract of the function rather than raising 400 from inside a background task. Audit sweep methodology: AST-walked every Python file underbackend/app/api/routes/ANDbackend/app/services/forPath / Nameshapes (the exact shape that produced the original report). 25 additional route-layer sites and 8 additional service-layer sites confirmed safe case-by-case (UUID-generated filenames written by Bambuddy itself,_safe_filename(...)/Path(arg).namebasename-stripped inputs,os.walk-discovered names, denylist + format-validated backup names, hardcoded constants iterated through a tuple, DB-stored paths whose write origin already goes through a resolved-and-containment-checked helper). Each safe site got a# SEC-PATH-OK: <reason>marker so future audits can trust the inline guard at a glance. Six pre-existing safe-with-marker sites (library.pyexternal upload,archives.pytimelapse output,projects.pyattachment download/delete,settings.pybackup extractall) carry the same marker shape. Fifth CI backstoptest_route_path_arithmetic_is_safe_joined_or_marked(backend/tests/unit/test_no_unsafe_path_joins.py) AST-walks every Python file inbackend/app/api/routes/ANDbackend/app/services/and fails the build on any<directory-variable> / <bare-variable>join that doesn't either route throughsafe_join_underor carry the marker on the join line. Joins matching the higher-structure shapes (Attribute access, Subscript, f-string,str(...)call) are categorically different and out of scope — those are caught by the broader audit sweep, not the regression backstop. The services layer is in scope because it receives values from the routes verbatim AND from external sources Bambuddy has no control over (the printer FTP-listing case above). Tests: 17 unit tests forsafe_join_undercovering every escape vector (absolute path, Windows abs path,..segments, embedded.., null byte, empty string, no parts, non-str, plus legitimate nested-path round-trip); 4 integration tests againstPOST /api/v1/projects/import/fileexercising the full FastAPI stack with the verbatim shape from the report (absolute path infolder_name→ 400 + filesystem assertion that the target file doesn't exist;..infolder_name→ 400;..inrelative_path→ 400; legitimate nested ZIP still imports cleanly to guard against the fix being over-strict); 3 unit tests againstArchiveService.attach_timelapseexercising the compromised-printer threat model (filename with..segments → returns False + no file at the escape target; absolute filename → returns False + no file at/tmp; legitimatetimelapse_YYYY-MM-DD_HH-MM-SS.mp4→ returns True + file lands inside archive_dir, guarding against the fix being over-strict). SECURITY.md gains a fifth rule + a fifth row in the CI-test mapping table; the rule explicitly names the printer FTP-listing case as in-scope to set the expectation for future services-layer audits. Full 5500+ test backend suite green; ruff clean.
Fixed
- Print-run log, spool usage history, camera-token list, and SpoolBuddy device "last calibrated" timestamps now render in the browser's local timezone instead of UTC (#1602, reported by @maziggy and confirmed by @IndividualGhost1905 with a UTC+3 reproduction) — Reporter saw print-run completion timestamps show UTC clock values (e.g.
07:50instead of the correct local10:50for Berlin /10:50instead of13:50for a UTC+3 host). Same shape as the #504 timezone-offset bug from Feb 2026 — frontend display helpers callingnew Date(isoString)directly on backend timestamps without timezone indicators. Per ECMAScript, a bare"2026-06-02T07:50:00"is parsed as local time, so a UTC-stored value gets displayed as if its numeric components were already local — visually identical to UTC. The #504 fix patched 13 sites but missed four: PrintLogTable and SpoolUsageHistory hadn't been written yet; CameraTokensPage and SpoolBuddySettingsPage existed but were overlooked. Fix — replaced the barenew Date(iso)calls infrontend/src/components/PrintLogTable.tsx::formatDate(per-archive Runs list — the reporter's literal symptom),frontend/src/components/SpoolUsageHistory.tsx::formatDate(spool usage records),frontend/src/pages/CameraTokensPage.tsx::formatDate+isExpired(long-lived camera token created / expires / last-used columns), andfrontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx::formatDateTime(SpoolBuddy device "last calibrated") with calls to the sharedparseUTCDate()helper fromutils/date.ts, which appendsZto naive ISO strings and parses TZ-tagged strings as-is — already used by every other date formatter in the codebase and well-tested (parseUTCDatehas 4 dedicated test cases covering null/empty/tagged/naive inputs).isExpiredinCameraTokensPage.tsxgot the same treatment because comparing a misparsed Date againstDate.now()would have produced false "not expired" / "expired" results around the TZ-offset boundary. What this does NOT fix — the printer-card ETA reporter #1 described (10:50 + 57m showing 09:48). That comes fromformatETA(status.remaining_time)which is purely client-side (new Date()plus minutes from the WebSocket payload, thentoLocaleTimeString([])); for it to render UTC the browser timezone itself would need to be UTC. That's a browser / OS config issue, not Bambuddy's display. If reporter #1 was actually looking at log timestamps (the same surface reporter #2 explicitly called out), this fix covers it; otherwise their ETA complaint stays a config matter. Audit confirmed no other regressions — grepped everynew Date(call infrontend/src/for backend-supplied string arguments. Remaining call sites either pass a number (epoch ms from chart data —AMSHistoryModal.tsx:327,349), construct from a numeric date string only with no time component (chart axis labels —FilamentTrends.tsx:57,129), use the result only for.getTime()arithmetic where the same TZ offset cancels out (sort comparators inStatsPage.tsx:920,ForecastPanel.tsx:101,111,112,141,FilamentTrends.tsx:70), or already wrap inparseUTCDate(...) || new Date(...)as defensive fallback (StatsPage.tsx:607,895).FailureDetectionSettings.tsx:359usesnew Date(ev.timestamp)directly but the backend (obico_detection.py:293) emitsdatetime.now(timezone.utc).isoformat()which includes a+00:00indicator so ECMAScript parses it as UTC correctly — unchanged. No new tests added — the fourformatDate/formatDateTimehelpers are local to their files and the bug is mechanical "use the existing helper";parseUTCDateitself has full coverage in__tests__/utils/date.test.ts. Frontend build clean, ESLint zero output, full date-utils + impacted-component vitest suites (103 tests) green, i18n parity green at 5007 leaves × 9 locales. - Archive card's Print Time + accuracy badge are now consistent for multi-run / multi-plate archives (#1608, reported via an AI-assisted diagnosis that included the failing SQL, file line numbers, and a worked example for archive #65) — Reporter's case: 3-plate
.gcode.3mfprinted plate-by-plate over 9 runs. Card showed1h 46m +188%next to the now-correct156.7g/$9.81. The 1h 46m = 6364 s = one run'scompleted_at − started_at; the +188% =print_time_seconds / 6364− 100 % whereprint_time_secondsis 18354 s (the whole-file estimate the #1593 parser fix correctly stores). The two halves describe different scopes — apples-to-oranges. Root cause —backend/app/api/routes/archives.py::compute_time_accuracy(line 152) only inspects the archive row's ownstarted_at/completed_at, which reflect the latest run, whilearchive.print_time_secondsis the sum across plates post-#1593. The existing 5-500 % sanity band catches truly broken values but lets the deterministic N×100% shape through (300% for a 3-plate file).archive_to_responsecallscompute_time_accuracyon every list / detail / search / project-archive / patch render (line 275), so the bad number reaches the frontend on every card surface. The stats endpoint (/api/v1/archives/stats, line 940-988) has its OWN per-run accuracy loop with a tighter 50-200 % band filter shipped with #1593 — that's untouched and stays correct. Fix —compute_time_accuracy(archive, run_aggregate=None)gains an optionalrun_aggregateargument. Whenrun_aggregate["run_count"] > 1, bothactual_time_secondsandtime_accuracyare returned asNone. The frontend already falls through toarchive.print_time_secondsfor the Time display (archive.actual_time_seconds || archive.print_time_seconds) and conditionally renders the badge only whenarchive.time_accuracyis truthy, so multi-run archives now show "Estimated 5h 6m" with no badge instead of "Actual 1h 46m +188%". Single-run archives — the case the badge was designed for, and the only case where one-run actual versus whole-file estimate is a meaningful ratio — keep the original behaviour verbatim. Audit-wide —archive_to_responsenow passesrun_aggregatethrough tocompute_time_accuracyat the response-conversion call site. The 3 endpoints that did NOT previously load run aggregates (backend/app/api/routes/archives.pysearch endpoint's pre-FTS fast path at line 583 and FTS path at line 610, the single-archive PATCH endpoint at line 1419, andbackend/app/api/routes/projects.py::list_project_archivesat line 706) now batch-load_load_run_aggregatesand pass it through, so the badge-suppression applies on every card surface — not just the main list and detail endpoints. One extraSELECT … GROUP BY archive_idper endpoint (the helper is already batched), cheap. Per the feedback_pr_reviews_thorough HARD RULE the fix is shipped across every call site that renders an archive card. What this does NOT change — the stats endpoint's per-run accuracy aggregation at line 940-988, its 50-200% band filter, thearchive.started_at/completed_atsource-of-truth for the latest-run timestamps, the frontendArchivesPage.tsx:1004-1022rendering logic, or the time-accuracy computation for single-run archives. Reprint scope is unchanged (the reporter's option A — comparing summed run durations against the whole-file estimate — was not pursued because it produces a different but equally misleading number for the reprints-of-a-single-plate-file shape, where sum-of-runs = N × estimate). Tests —backend/tests/unit/test_archive_run_aggregation.py::TestComputeTimeAccuracyMultiRun: 4 new direct unit tests for the function — single-run archive keeps original badge, norun_aggregateargument keeps original badge (defends the legacy caller pattern), multi-run archive (reporter's exact 9-run case) clears both fields, andrun_count: 0edge case keeps original behaviour. Two new integration tests against the live archives list endpoint:test_archive_list_suppresses_time_accuracy_for_multi_run_archivesis the #1608 regression (3-plate plate-by-plate fixture, asserts bothactual_time_seconds is Noneandtime_accuracy is NoneAND that the estimateprint_time_secondssurvives so the card has something to render), andtest_archive_list_keeps_time_accuracy_for_single_run_archivesis the sanity check that the badge still shows for the happy path. Full backend pytest 3680 passed under-n 30; ruff clean. No frontend change required — the existing rendering logic inArchivesPage.tsx:1004-1026(formatDuration(archive.actual_time_seconds || archive.print_time_seconds || 0)for the time +{archive.time_accuracy && …}conditional badge) naturally produces the desired "show estimate, no badge" presentation when the backend returns null for both fields. - Queue / Review / Archive virtual-printer modes now complete the TLS handshake on hardened-distro hosts (#1610, reported by an AI-assisted diagnosis) — Reporter ran Bambuddy in a container on a hardened-policy host (Fedora / RHEL with
update-crypto-policiesor similar) and OrcaSlicer / BambuStudio failed to connect to any non-proxy-mode virtual printer withcode=-1after the TCP handshake completed. Switching the same VP to Proxy mode worked. They grepped the container and pinpointed that the #620 cipher-suite fix only patchedtcp_proxy.py::_create_client_ssl_context(printer-facing) and missed every other slicer-facing VP TLS context. Diagnosis confirmed: real Bambu printers (and slicer paths written to mimic them) offer only the plain-RSA AES-GCM suitesAES256-GCM-SHA384/AES128-GCM-SHA256; on stock OpenSSL these stay inDEFAULT, but a system crypto policy that strips them leaves the server side offering ECDHE-only and the slicer's ClientHello finds no overlap — handshake aborts before any data flows. Audit-wide fix (4 contexts patched, one regression test class per site): (a)backend/app/services/virtual_printer/bind_server.py::_create_tls_context(port 3002, used by Queue/Review/Archive modes — the literal reported failure) — addedctx.set_ciphers("DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256"); (b)backend/app/services/virtual_printer/mqtt_server.py::SimpleMQTTServer.start(port 8883, slicer MQTT-over-TLS) — same cipher pin; (c)backend/app/services/virtual_printer/tcp_proxy.py::TLSProxy._create_server_ssl_context(slicer side of Proxy-mode 3002 — the #620 fix's other half that was never written) — same cipher pin; (d)backend/app/services/virtual_printer/ftp_server.py::VirtualPrinterFTPServer.start(port 990, FTPS upload) — changed from the historicalHIGH:!aNULL:!MD5:!RC4toHIGH:AES256-GCM-SHA384:AES128-GCM-SHA256:!aNULL:!MD5:!RC4. TheHIGHbaseline is kept verbatim (not replaced withDEFAULT) so the cipher set stays a strict superset of what shipped before — the originalHIGHset offers ~58 ciphersDEFAULTdoesn't (CCM, ARIA, CAMELLIA, DSS variants); none are picked by any known Bambu slicer, but the feedback_dont_remove_compat_pinning HARD RULE says don't narrow a compat surface without proof. The!aNULL:!MD5:!RC4exclusions are preserved as well. For the three new contexts (a/b/c), the cipher string isDEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256— verbatim match with the #620 client-side fix. Verified strict-superset at audit time ({c['name'] for c in old.get_ciphers()}.issubset({c['name'] for c in new.get_ciphers()})returns True for all four call sites). Per the feedback_vp_regression_matrix HARD RULE, the slicer-facing TLS surface gets the fix in one drop instead of a per-mode-per-issue ticket trail. Per feedback_dont_remove_compat_pinning, theminimum_version = TLSv1_2and the FTPSmaximum_version = TLSv1_2pins (the BambuStudio FTPS-data-channel-PSK-reuse compat) are unchanged — only the cipher list is widened, never narrowed. Tests (backend/tests/unit/test_vp_tls_ciphers.py, new file): 5 cases — one per slicer-facing surface (bind / mqtt / proxy-server / ftp) assertingAES256-GCM-SHA384ANDAES128-GCM-SHA256are in the SSL context's offered cipher list, plus a guard test that the original #620 client-sidetcp_proxy._create_client_ssl_contextstill has them so this audit's edits can't accidentally regress that fix. Cipher assertions use the productionCertificateServiceto generate a real self-signed CA + per-VP cert pair intotmp_path(rather than mockingload_cert_chain) so the SSL contexts are configured exactly as production would. Full backend unit suite 3674 passed under-n 30; ruff clean. Why neither maintainer nor the local CI box reproduces the bug: stock OpenSSL 3.x shipsAES256-GCM-SHA384/AES128-GCM-SHA256inDEFAULTalready, so the missingset_ciphers()calls were harmless on most builds — verifiable withpython -c "import ssl; print(c['name'] for c in ssl.SSLContext().get_ciphers())". The bug only manifests on builds where a system crypto policy or vendor build flag narrows the default to forward-secrecy-only. The explicit cipher pins now survive any such narrowing. What this does NOT do: the printer-side TLS surface (proxy client context, MQTT printer-client context inbambu_mqtt.py) is unchanged — printers always sit behind the unfiltered #620 client pin, and there's no equivalent failure mode reported there. - External-spool usage is now tracked when the AMS has empty slots in between loaded ones (#1607, reported by @ahmtcnby) — Reporter had AMS slots 0–2 loaded, slot 3 empty, and an external spool. After every multi-filament print, the external's weight never decremented; assigning the external spool to the empty AMS slot 3 in Bambuddy made the deduction appear correctly. Root cause: when no explicit slot-to-tray mapping is available (path 5 of 6 in
usage_tracker.py::_track_from_3mf, e.g. the very first print after a fresh container start before the request-topic subscription that capturesams_mappingfromprint_commandis accepted — the bundle showedRequest topic subscription accepted. ams_mapping capture enabledonly fired at 19:36:29), the tracker falls back to a position-based mapping built fromspoolman_tracking.py::build_ams_tray_lookup. That helper enumerated every AMS tray byidregardless of whether a spool was loaded, so the reporter's layout yieldedavailable_trays = [0, 1, 2, 3, 254]. BambuStudio / OrcaSlicer compact their filament-assignment UI by hiding unloaded AMS slots — the slicer's 4th filament is the external, so the 3MF carries slot_ids 1–4 with slot 4 = external. Position-based mapping then routed slot 4 →available_trays[3]= AMS0-T3 (the empty slot) instead of 254 (the external). No spool assignment exists at AMS0-T3, so usage was silently skipped at line 1273-1274 and the external spool's weight stayed unchanged. Fix (backend/app/services/usage_tracker.py:1232-1245): the position-based fallback now filtersbuild_ams_tray_lookup's output to slots whosetray_typeis non-empty before sorting.build_ams_tray_lookupitself is unchanged (its other callers —spoolman_tracking.store_print_data,routes/printers,spool_assignment_notifications— want every physical slot for AMS-state purposes); the filter is applied at the call site so we only narrow what the fallback uses. vt_tray entries are already filtered the same way insidebuild_ams_tray_lookupat line 174 (if vt.get("tray_type"):) — this mirrors that behaviour for the AMS side. Why the reporter's workaround helped: assigning the external spool to AMS0-T3 in Bambuddy made the wrong-target rewrite accidentally land on the right spool — the empty AMS slot resolved to the same spool the external was fed from. The fix removes the need for that workaround. Tests: 2 new inbackend/tests/unit/test_usage_tracker.py::TestPositionBasedFallbackEmptyAmsSlot—test_external_routed_correctly_when_ams_has_empty_middle_slotis the literal #1607 regression (3 AMS slots loaded + 1 empty + external loaded, slicer's slot 4 must charge spool at AMS255-T0, must NOT charge anything at AMS0-T3 — explicit(0, 3) not in handled_traysassertion);test_dense_ams_unchanged_no_empty_slotsis the no-empty-slots sanity check that confirms the fix doesn't regress the everyday case (4 AMS slots all loaded + external → slot 5 still maps to external). Path priority order unchanged: explicitprint_cmd/ MQTT / queue / color-match mappings still override the position-based fallback, so this only changes behaviour when none of those paths fired. Full backend pytest 3669 passed under-n 30; ruff clean. - Custom maintenance type "documentation URL" now persists on create (#1596, reported by @BurntOutHylian — with the exact root cause pre-triaged in the issue body) — POST
/api/v1/maintenance/typeshard-coded every field on theMaintenanceTypeconstructor by name (name,description,default_interval_hours,interval_type,icon,is_system) and silently droppedwiki_url, even though the Pydantic schema accepted it and the response model echoed it back asnull. PATCH was fine because it useddata.model_dump(exclude_unset=True) + setattr, which is why editing a freshly-created type DID save the URL — masking the bug under any "save then immediately fix it" test. Fix: addwiki_url=data.wiki_urlto the constructor call atroutes/maintenance.py:206. Frontend nit also addressed in the same drop (#1596 nit section):MaintenancePage.tsx:1131updateTypeMutation's inlinePartial<{...}>shape listedname | default_interval_hours | interval_type | icononly. The value reached the API correctly at runtime becauseapi.updateMaintenanceTypeacceptsPartial<MaintenanceTypeCreate>(which includeswiki_url), but the local type was misleading — anyone reading the mutation would wrongly concludewiki_urlwasn't part of the update payload. Extended the inline shape to includewiki_url?: string | null. Tests: one new integration test intest_maintenance_api.py::test_create_custom_type_persists_wiki_url— POSTs a custom type with awiki_url, asserts the POST response carries it, and verifies via a separate GET round-trip that the value actually committed (defending against the "response echoes request body" failure mode the bug would have masked). Full 5565-test backend suite green; ruff clean; frontend build clean; ESLint zero output; touched MaintenancePage vitest green. - External-folder
.gcode.3mffiles now show thumbnails, and every ingest path stores the same canonicalfile_typefor sliced outputs (#1600, reported by @maziggy) — Reporter noticed external-folder sliced outputs landed with no thumbnail. Cause: four backend ingest paths classifiedLibraryFile.file_typedifferently for the same.gcode.3mffamily. The upload, ZIP-extract, and in-process paths usedos.path.splitext(filename)[1]which returns.3mfforfoo.gcode.3mf, storedfile_type="3mf", and matched the thumbnail-extraction gate atlibrary.py:1467(if file_type == "3mf":). The external-folder scan path explicitly detected the compound and setfile_type="gcode.3mf"— preserving the "sliced output" identity — but then skipped bothif file_type == "3mf":(mismatch) andif file_type == "gcode":(also mismatch), so the file landed withthumbnail_path = None. Same compound-extension drift that bit #1543's 3D preview gates, just in a different surface that the #1543 frontend audit didn't trace back to. Unified fix (per the user's "unify if it's safe" directive): newclassify_file_type(filename)helper inlibrary.pyis now the single source of truth — returnsgcode.3mffor sliced outputs andext[1:]otherwise. Applied to every ingest path: upload (routes/library.py:1704), ZIP-extract (routes/library.py:1998), external-folder scan (the bug site, plus the manual compound check is replaced), and the in-processsave_3mf_from_bytes()helper (routes/library.py:471— used by MakerWorld import). The external-scan thumbnail gate is widened toif file_type in ("3mf", "gcode.3mf"):so a sliced output now goes through ThreeMFParser (a.gcode.3mfIS a 3MF zip withMetadata/plate_1.pngthumbnail; the parser doesn't care about the trailing extension). The gcode-download endpoint atGET /api/v1/library/files/{id}/gcode(routes/library.py:4390) had the same drift in reverse — its gate waselif file.file_type == "3mf":so a row stored withfile_type="gcode.3mf"(the external-scan path's pre-unification behaviour, and now the canonical going forward) was rejected with HTTP 400. Widened toelif file.file_type in ("3mf", "gcode.3mf"):so both ingest histories work. One-shot DB migration inbackend/app/core/database.py::run_migrationsbackfills existing legacy rows:UPDATE library_files SET file_type='gcode.3mf' WHERE file_type='3mf' AND LOWER(filename) LIKE '%.gcode.3mf'. Idempotent (post-update rows no longer match thefile_type='3mf'predicate, so re-runs at every boot are no-ops) and dialect-neutral (LOWER+LIKEare identical under SQLite and Postgres per the feedback_sqlite_and_postgres_upfront HARD RULE; behaviour-identical on Postgres by construction, tested explicitly on SQLite in the new regression suite). Without the backfill, users would have a permanent split state in the DB — old uploads at3mf, new uploads atgcode.3mf— which would (a) double-bucket sliced outputs in the dashboard stats query atroutes/library.py:4615(SELECT file_type, count(*) GROUP BY file_type) and (b) show two entries in the file-manager filter dropdown for the same conceptual type. Frontend untouched —FileManagerPage.tsxandProjectDetailPage.tsxalready accept both'3mf'and'gcode.3mf'for Preview-3D, type-pill colour, and the file action gate per the #1543 fix. After the migration the DB only contains canonical values, so the legacy'3mf'branches in the frontend become dead code for sliced files — they stay in place to handle any future ingest path I missed (defence in depth — better a redundant gate than an empty card). Tests: 13 new intest_library_classify_file_type.pycovering the helper across every compound / casing / no-extension case; 3 new intest_library_file_type_backfill_migration.py(legacy.gcode.3mf/3mfrow backfilled, mixed-case filenames upgraded viaLOWER(), unrelated.bak-suffixed compound substring left untouched, plain.3mf/ raw.gcode/.stluntouched, idempotent on re-run); 2 new integration tests intest_library_api.py(upload of.gcode.3mfnow storesfile_type="gcode.3mf"via the unified path; the gcode-download endpoint accepts a row withfile_type="gcode.3mf"and returns the embedded gcode). Full backend pytest 5564 passed under-n 30; ruff clean; frontend build clean; eslint zero output; i18n parity green at 5007 leaves × 9 locales. - Virtual-printer "Send file" IP rewrite now also fires for VPs without a dedicated bind IP (#1429 follow-up, residual case confirmed by @Mape6 on the 2026-06-02 daily) — The first #1429 fix's
_refresh_ip_encodingearly-returned whenmqtt_server.bind_addresswas0.0.0.0or empty (which is the default for any VP created without a bind IP selected — covered by the "Deferred (fix D)" note in the original #1429 changelog entry). On a flat-LAN install that's the typical case, so for those VPs the encoding never armed,_rewrite_net_info_ipswas a no-op on every push, and the slicer kept following the real-printer IP to the printer's SD card — the exact symptom @Mape6 reported after pulling the 2026-06-02 daily that supposedly fixed this. Fix (backend/app/services/virtual_printer/mqtt_bridge.py): new_resolve_host_interface_for_target()helper consults the existingnetwork_utils.find_interface_for_ip()to pick the host interface in the same subnet as the printer's IP whenbind_addressis unspecified._refresh_ip_encodingnow falls back to that auto-resolved IP instead of returning early; an explicit bind IP still takes precedence. INFO log line distinguishes the two paths (armed: ... (bind_address)vsarmed: ... (auto-resolved)) so support bundles answer "which IP did the rewrite pick?" without re-reasoning. If no interface matches the printer's subnet (the helper returns None), the bridge leaves encoding unarmed and the cache flows through as before — no crash, no wrong rewrite. Tests — 4 new inbackend/tests/unit/test_vp_mqtt_bridge.py::TestBindAddressAutoResolve: rewrite arms via auto-resolved IP when bind_address is0.0.0.0; rewrite stays disabled when no host interface matches (no crash); explicit bind_ip takes precedence over auto-resolve; helper itself returns None whenfind_interface_for_ipdoes. All 39 mqtt_bridge tests pass; full backend unit suite (3667 tests) green; ruff clean. Note on subnet matching: the helper is best-effort — it picks the interface whose subnet contains the printer's IP, which is the right answer when slicer + printer + Bambuddy share a LAN (the typical home-lab case). Setups where the slicer reaches Bambuddy via a different interface than Bambuddy uses to reach the printer (multi-homed hosts, Tailscale + LAN where the slicer is on Tailscale and the printer on LAN) may still need an explicit bind IP — there's no leak in that case, just a rewritten value the slicer can't route to. The full audit-shaped resolution (enumerate accepted connections, per-slicer rewrite) is still a separate change. - Virtual-printer "Send file" no longer redirects from Bambuddy to the physical printer's SD card once the printer powers on, and the mode button labels finally match the wire values stored in the DB (#1429, reported by @TrickShotMLG02, confirmed by @Mape6) — Two reporters on completely different network topologies (3-subnet routed via OPNsense vs. flat single-LAN) saw the same symptom: with the physical printer off and Bambuddy freshly restarted, the slicer's "Send" landed in Bambuddy's archive; once the printer powered on, every subsequent "Send" went straight to the printer's SD card and bypassed Bambuddy entirely. @Mape6's packet capture on the flat-LAN case ruled out subnet / mDNS-reflector / firewall theories — the slicer just had a non-Bambuddy IP for the FTP destination once the printer was online. Bundle analysis:
mape6-before(printer off) showed clean FTP receive + archive lines;mape6-after(printer on) had zero FTP connection attempts to Bambuddy, full stop. The mode-label discrepancy in every support bundle was a separate red herring that needed clearing up in the same drop. Root cause —backend/app/services/virtual_printer/mqtt_bridge.py::_on_printer_rawcaches the real printer'spush_statusand rewritesnet.info[*].ipfrom real-printer LE-uint32 to VP-bind-IP LE-uint32 so the slicer's FTP destination resolves to the VP. The rewrite has been in tree since 2026-05-03 and the unit test that ships with it passes. But the encoding (_target_ip_uint32_le,_vp_ip_uint32_le) was only computed inside_resolve_clienton client-identity change, and_resolve_clientearly-returned (if current is self._target_client: return) on every refresh tick when the same client object was still bound. So if the printer's MQTT client object existed butip_addresswas empty/stale at first bind (e.g. the printer's DB row hadn't picked up its discovered IP yet, or the client was constructed before the SSDP refresh), the encoded LE-uint32 stayedNone, the rewrite block was skipped, the cache filled with the real printer IP, the sticky-keys preservation in the same function kept that poisonednetvalue alive across every subsequent incremental push, and the slicer followed the leaked IP to the real printer. The only way to clear it was to restart Bambuddy with the printer off — which is exactly the workaround both reporters independently arrived at. Same shape on multi-NIC printers: the rewrite only matched entries whoseipequalled_target_ip_uint32_le, so an X1C / H2D Pro reporting two active interfaces (WiFi + Ethernet) would have one entry rewritten and the other leaking the printer's other IP — a separate FTP fallback path that bypasses the VP even when the primary rewrite worked. Fixes (mqtt_bridge.py): (1)_resolve_clientnow calls a new_refresh_ip_encoding()helper on every refresh tick, even when the client identity is unchanged — re-readscurrent.ip_address, re-encodes if either side changed, self-heals onceip_addressbecomes valid. (2) When the encoding becomes valid for the first time after the cache has already been populated,_refresh_ip_encoding()sweeps the cached_latest_print_statevia the new_rewrite_net_info_ips()helper so the slicer's next pull sees the rewritten value — without this, sticky-key preservation keeps the poisoned cache alive across every incremental update. (3)_rewrite_net_info_ips()rewrites every non-zeronet.info[].ipentry that doesn't already equal the VP bind IP, not only entries matching_target_ip_uint32_le— defensive against multi-NIC printers, against_target_ip_uint32_lebeing stale, and against unknown secondary interfaces leaking. Zero-IP entries (placeholders for unpopulated interfaces) are deliberately left alone so the slicer's "active interface" detection still recognises them as absent. (4) The rewrite path now logs at INFO when encoding arms or updates and at INFO when the cache sweep rewrites entries, so future support bundles directly answer "did the rewrite fire?" without re-reasoning about timing. Mode wire-value rename (#1429 follow-up, separate confusion source) — The UI button labeled "Archive" had always saved the wire valueimmediate, and "Queue" had always savedprint_queue. Both reporters' support bundles showedmode: immediatewhile the UI said "Archive", and @TrickShotMLG02 specifically asked "I have no idea why it says immediate in the support-info.json file. In the webui the printer is set to archive". The mismatch was load-bearing for the debug session and had to be cleared up. Canonical wire values are nowarchive/review/queue/proxymatching the button labels 1:1. Backend rename: newbackend/app/models/virtual_printer.py::VP_MODE_*constants +normalize_vp_mode()helper accepts legacyimmediate/print_queueand translates to canonical.VirtualPrinter.modedefault flipped toarchive.backend/app/services/virtual_printer/manager.py::VirtualPrinterInstance.__init__normalises on construction so a legacy DB row read before the migration window has finished still dispatches to the correct handler;on_file_received,on_print_command, andsync_from_db's change-detection all consume canonical values vianormalize_vp_mode().backend/app/api/routes/virtual_printers.py::create_virtual_printerandupdate_virtual_printeraccept both forms on input and normalise to canonical before storage;backend/app/api/routes/settings.py::get_virtual_printer_settingsnormalises on read so frontend mode-button highlighting works for legacy stored values;update_virtual_printer_settingsaccepts and normalises on write.backend/app/schemas/settings.py::AppSettings.virtual_printer_modedefault flipped toarchivewith updated description. One-shot DB migration:backend/app/core/database.py::run_migrationsrewrites everyvirtual_printers.modeandsettings.virtual_printer_moderow fromimmediate→archiveandprint_queue→queue. Idempotent — re-running on canonical values is a no-op, important because the full migration set runs every boot. Identical statement under SQLite and Postgres (plainUPDATE ... WHEREon a string column, no dialect-specific syntax) per the feedback_sqlite_and_postgres_upfront HARD RULE; tested explicitly on SQLite in the new regression suite, behaviour-identical on Postgres by construction. The historical single-VP migration (legacysettingsrows →virtual_printerstable on first multi-VP boot) gets the sameimmediate→archive/print_queue→queuetranslation; the historicalqueue→reviewalias is preserved because it predates the rename and reflected the user's intent at the time (the old wirequeuemeant "pending review", not "add to print queue"). Frontend rename:VirtualPrinterSettings.tsx,VirtualPrinterCard.tsx, andVirtualPrinterAddDialog.tsxall switched their button click handlers andLocalMode/Modetype aliases from'immediate' | 'review' | 'print_queue' | 'proxy'to'archive' | 'review' | 'queue' | 'proxy'. Each file gained its ownnormalizeMode()helper that translates legacy values arriving via stale-cached settings payloads to canonical, so the right mode button lights up even when the backend migration hasn't completed for that user's session yet. The twoprinter.mode === 'queue' ? 'review' : printer.modelegacy mappings inVirtualPrinterCard.tsx::useEffectand the error-recovery path have been replaced withnormalizeMode()— they were the source of the test failure I caught mid-implementation wheremode: 'queue'(the new canonical for the Queue button) was being incorrectly aliased back to'review'and hiding the auto-dispatch + force-color-match toggles.frontend/src/api/client.ts::VirtualPrinterModeis now the union of both canonical and legacy values ('archive' | 'review' | 'queue' | 'proxy' | 'immediate' | 'print_queue') so older API clients (forks, mobile shortcuts, scripted setups) typecheck; theupdateSettingsbody type narrows to canonical-only to steer new code. Mode handler is NOT the dispatch bug:manager.py::_archive_fileis the handler forarchivemode and it does archive-only (no dispatch to the physical printer). The user-visible "files end up on the printer's SD card" symptom was the IP-leak from the bridge cache, not a mode-dispatch bug. The mode rename is purely a clarity / support-bundle-accuracy fix. Tests —backend/tests/unit/test_vp_mqtt_bridge.py: 2 new in the bridge-rewrite class —test_net_info_ip_rewritten_for_unknown_secondary_interfacecovers the multi-NIC X1C / H2D Pro case where the printer reports an interface IP Bambuddy never saw; both entries get rewritten, the placeholder zero entry stays untouched.test_late_arriving_printer_ip_rewrites_existing_cacheis the primary #1429 regression — bridge binds to a client withip_address="", first push lands and poisons the cache with the real-printer IP (the pre-fix state), the printer'sip_addressthen becomes known, the next_resolve_clienttick arms the encoding AND sweeps the cachednet.info[].ipso the slicer's next pull sees the VP IP. Without the sweep, sticky-key preservation would keep the poisoned value alive forever.backend/tests/unit/test_vp_mode_rename_migration.py: new file, 3 tests — legacyimmediate→archiveandprint_queue→queuerewrites under SQLite, canonical values pass through untouched; legacyvirtual_printer_modesetting also gets rewritten; running the migration twice is idempotent (every boot re-runs the full migration set).backend/tests/integration/test_virtual_printer_api.py: 3 reworked tests cover input-side normalisation —test_update_mode_to_queueasserts canonical,test_update_mode_legacy_print_queue_normalises_to_queueandtest_update_mode_legacy_immediate_normalises_to_archiveassert legacy → canonical translation on storage. The pre-existingtest_update_mode_legacy_queue_maps_to_review(predating the rename, asserted the oldqueue→reviewalias) is removed; the newtest_update_mode_to_archivecovers canonical archive setting. All other VP tests were updated to canonical —test_virtual_printer.py(43 occurrences),test_vp_diagnostic.py(1),test_virtual_printer_api.pymocks (5) renamed; thesync_from_db_restarts_on_mode_changetest had to be repaired by hand because the sed pass made both sidesarchive(defeating the change detection); now usesarchive→reviewto actually exercise the change branch. Frontend:VirtualPrinterCard.test.tsx,VirtualPrinterSettings.test.tsx,VirtualPrinterDiagnosticModal.test.tsxupdated to canonical fixtures and assertions; the legacyqueue maps to reviewtest inVirtualPrinterSettings.test.tsxreplaced with two tests — legacyimmediatelights up the Archive button, legacyprint_queuelights up the Queue button, both via the new client-sidenormalizeMode()helper. The fiveInventoryPage*.test.tsxfiles that hardcodedvirtual_printer_mode: 'immediate'in their settings mocks bulk-renamed to'archive'. CI gates green: backend pytest 5546 passed in 73.88s + 7.10s under-n 30/-n 12parallel; ruff clean; frontendnpm run buildclean (TypeScript + Vite); ESLint zero output; vitest 2045 passed in 26.12s; i18n parity script clean at 5007 leaves × 9 locales. Deferred (fix D in the diagnosis writeup): bind_ip == 0.0.0.0 path. The rewrite is still explicitly skipped when bind_ip is the unspecified address, which is correct for the routing (you can't tell a slicer to FTP to 0.0.0.0) but leaves users without a dedicated bind IP exposed to the same IP-leak pattern. Both reporters hadhas_bind_ip=truein their bundles so this isn't load-bearing for #1429 itself; will be addressed as a separate audit-shaped change that needs to enumerate the host's outbound IPs and pick the one that can reach the printer, with its own test surface. Out of scope for this PR: port 40024 in @Mape6's packet capture (Bambu Network Plugin's LAN-Send pre-flight port) — a probe that arrives at the VP IP, finds no listener, and the slicer falls back. Adding a 40024 listener is conceptually a different surface (handshake parsing, not MQTT cache state) and the cache-leak fix alone removes the underlying redirection so the 40024 probe lands on a VP that's actually the right destination. Will reassess if either reporter still sees mis-routing after this fix. - Multi-plate
.gcode.3mfarchives + reprints no longer under-report filament, time, and cost — project stats and parser both fixed (#1593, reported by @needo37) — Reporter printed 3 plates of a multi-plate file: Archive Print Log correctly recorded 3 completed runs at distinct durations and filament weights; Project page showedPrint Jobs: 1 / 1 parts printed, plate-1's1h53m / 58g / $1.09; Archive card said3 printsbut rendered plate-1's57.6g / 1h45m / 1 object. Two distinct causes stacked. Root cause 1 — 3MF parser only read the first plate:ThreeMFParser._parse_slice_info(backend/app/services/archive.py:191) calledroot.find(".//plate")and pulledprediction/weightfrom that one element — so for any multi-plate file the archive's file-levelprint_time_seconds/filament_used_gramsreflected plate 1 alone. The per-plate/platesendpoint already loopedfindall(".//plate")and was correct, which is why the plate carousel showed the right numbers while the archive card was wrong. Root cause 2 — project rollup aggregatedPrintArchive, not the per-run log:compute_project_statsand thelist_projectsquick-stats block (backend/app/api/routes/projects.py) summedPrintArchive.print_time_seconds / filament_used_grams / cost / energy_*WHERE project_id = X. A reprint reuses the source archive row and only adds a newPrintLogEntry, so 3 sequential runs of one file collapsed to 1 archive — and that archive's numbers were already plate-1-only because of root cause 1. The Archive Print Log path was correct because it already drove offprint_log_entries(archives.py:420— "Reads from print_log_entries so reprints contribute each run"); project stats just hadn't been pointed at the same source. Parser fix:_parse_slice_infonow loopsfindall(".//plate")and sumsprediction→print_time_secondsandweight→filament_used_gramsacross all plates. Per-plate concepts (plate_number,_plate_index,printable_objects) are only set when there's exactly one plate — for multi-plate exports the archive represents all plates and a single plate index is meaningless at the file level.bed_typekeeps the first plate's value as a best-effort archive default. Malformedprediction/weightvalues on individual plates skip cleanly rather than poison the sum. Stats fix:compute_project_statsand thelist_projectsquick-stats block both switch to an inner joinprint_log_entries → print_archivesWHERE archives.project_id = X.total_archivesbecomesCOUNT(PrintLogEntry.id)(actual runs, not files);failed_printsbecomes the count of runs infailed/aborted/cancelled/stopped;completed_itemsbecomesSUM(PrintArchive.quantity)filtered to runs withstatus='completed'(each run contributes its archive's quantity);total_print_time_hours / total_filament_grams / estimated_cost / total_energy_*come fromPrintLogEntrycolumns. Orphan log rows (archive_id IS NULLafter archive deletion viaON DELETE SET NULL) are excluded by the inner join — they can't be attributed to any project. Backfill behaviour (intentional, matches the reporter's "forward-only" note): users with AMS spool tracking — the reporter's case — have per-runPrintLogEntry.filament_used_gramsfrom the tracked spool delta, not the plate-1 estimate, so project stats become correct immediately after the rollup fix with no reslice required. Users without tracking fall back to the archive estimate; their stats undercount until they reprint with the fixed parser. The Archive card still readsPrintArchive.filament_used_gramsdirectly, so old archives keep their plate-1-only numbers until a reslice/rescan repopulatesfile_metadata. Same-shape fix carried forward:system.py::system_info(the System Info page's lifetime totals) summedPrintArchive.print_time_seconds/filament_used_gramswith the identical bug — reprints collapsed to one archive, multi-plate files reported plate-1-only. The route now sums fromPrintLogEntry.duration_seconds/filament_used_gramslike the project rollup, so every run contributes its measured per-run actual. Same-shape fix in the time-accuracy metric (archives.py::get_archive_stats): the metric computedestimate / actualper run whereestimate = PrintArchive.print_time_seconds. Post-parser-fix multi-plate archives have file-level estimate but per-run actual = one plate's duration → ratio ≈ N×100% for an N-plate file (300% for the reporter's 3-plate case), which would drag the printer-level average to noise. The calc now clamps each row to the [50%, 200%] plausibility band before contributing to the average; single-plate accuracy is fully included (the case the metric is designed for), multi-plate plate-by-plate runs and one-off outliers (manual intervention, purge waste blowing the estimate) are excluded. Tests: 4 new intest_archive_service.py::TestMultiPlateSliceInfoSum— three-plate file sums prediction + weight (the reporter's exact numerics: 7140+6000+6300 → 19440s, 19.2+20.0+18.8 → 58.0g); single-plate path preservesplate_number+ objects + bed_type; multi-plate ignores per-plate object lists; malformed per-plate values are skipped without poisoning the sum. 4 new intest_projects_api.py::TestProjectStatsPerRun— 3 reprints show as 3 jobs with summed totals (matches the reporter's exact 3-run scenario); orphan log entries don't bleed into any project; mixed-outcome archive splits cleanly betweencompleted_prints(quantity-weighted) andfailed_prints(run-counted); list-view quick stats agree with per-project stats. 1 new intest_archive_run_aggregation.py— the accuracy band filter excludes multi-plate plate-by-plate runs (estimate 18000s / actual 6000s = 300%) so a single-plate file's near-100% reading stays the printer's average. Two pre-existing assertions updated to reflect the corrected semantics:archive_countandtotal_archivesnow count runs, so files attached but never printed (status"archived") contribute 0 — that's the right answer, not a regression. Full backend suite + ruff clean. - Webhook printer-status / stop / cancel routes 500'd on every connected printer because the route treated the PrinterState dataclass as a dict (#1584, reported via in-app bug report) — Reporter saw
GET /api/v1/webhook/printer/{id}/statusreturn500 Internal Server Errorwith a valid API key carrying theread_statusscope, whileGET /api/v1/system/inforeturned 200 with the same key — so auth and routing were fine, the handler itself was crashing. Cause:printer_manager.get_status(printer_id)returns aPrinterStatedataclass (backend/app/services/bambu_mqtt.py), not a dict. The route atwebhook.py:266-270calledstatus.get("connected", False),status.get("state"),status.get("current_print"),status.get("progress"),status.get("remaining_time")— every one raisedAttributeError, which Starlette surfaced as a generic 500. Reporter's id-1 (printer exists) returned 500; non-existent ids returned 404 — exactly because the earlyPrinter not foundbranch fired before reaching the crash. Same shape in two adjacent routes:webhook_stop_print(POST /printer/{id}/stop) andwebhook_cancel_print(POST /printer/{id}/cancel) checkedstatus.get("connected")/status.get("state")for their precondition gates. 8 crash sites total across the three routes. Fix: everystatus.get("X", default)replaced with attribute access (status.X if status else default); Pydantic response schema unchanged.PrinterState's dataclass defaults cleanly cover thestatus is Nonebranch (printer registered but never connected — the route now returns 200 withconnected=false, state=null, …rather than crashing). Tests (backend/tests/integration/test_webhook_printer_status.py): 7 new — status route returns 200 with the dataclass attributes mapped into the response (regression for the exact #1584 shape); status route returns 200 with sensible defaults whenget_status()returns None; status route returns 404 for a non-existent printer (control case proving the auth path is unaffected); stop route returns 503 when disconnected (pre-fix would have 500'd here); stop route returns 409 when state is notRUNNING; cancel route returns 503 when disconnected; cancel route returns 409 when state is notRUNNING/PAUSE. Runtime-verified end-to-end against a live PG-backed instance before and after: same key + same printer id, 500 before the patch and 200 with the correct payload after. Full backend suite + ruff clean. - Path-traversal CI backstop now recognises markers on the closing-paren line (project-wide convention) —
test_no_unsafe_path_joins.py::test_route_path_arithmetic_is_safe_joined_or_markedAST-walks every Path-arithmetic site inapi/routes/+services/and demands eithersafe_join_under(...)or a# SEC-PATH-OK: <reason>marker. The marker-detection helper only scanned the BinOp's own line range (lineno..end_lineno), but the project's convention puts the marker on the line of the wrapping closing paren — one pastend_lineno. The backstop flagged 30 already-marked, already-safe sites as findings, masking the fact that the post-GHSA marker work is complete. The helper now peeks one line pastend_linenoIF that line begins with a continuation token (),],},,), capturing exactly this convention without giving a free pass to a marker on a wholly unrelated next statement. 5 new tests inTestMarkerDetectionpin the contract: marker on the BinOp line recognised; marker on the closing-paren line recognised; an unrelated marker on a later statement does NOT silence; a marker on a non-continuation line right after the BinOp does NOT silence; no marker anywhere is still flagged. Integration test now passes against the existing tree — 30 findings → 0 — with no changes to any guard / sanitisation in routes or services. - Deleted local profiles no longer linger in the SliceModal preset dropdown; new manual "Refresh" button surfaces cloud-side deletions without waiting for the 5-minute cache (#1581, reported by @lloydjohnson) — Reporter saw deleted local AND cloud profiles still appearing in the slice menu after removing them. Two distinct causes wired together. Local half (real bug):
LocalProfilesView's import and delete mutations invalidated['localPresets'](the Local Profiles management view's own query) but not['slicerPresets']— the SliceModal reads from the unified/slicer/presetsendpoint via a separate React Query key (SliceModal.tsx:425,staleTime: 60_000), so a freshly-deleted preset kept rendering in the dropdown until the modal's 60 s staleTime elapsed plus a refocus / remount. The backend was correct end-to-end (delete_local_presetremoves the DB row,get_db()auto-commits,_fetch_local_presetsreads fresh from DB with no backend cache). Both mutations now also invalidate['slicerPresets']so the next modal open shows the current set. Cloud half (by-design backend cache + new opt-in bypass):_fetch_cloud_presetskeeps a 5-minute per-(user, token) in-process cache balancing "users see their freshly-saved presets quickly" against "a busy install doesn't hit Bambu Cloud once per modal open" (slicer_presets.py:69). The user deletes cloud presets in Bambu Studio / Bambu Handy, not in Bambuddy, so there's no event hook to invalidate on — the cache only refreshes when the TTL expires. Rather than shorten the TTL (which would effectively rate-limit the cloud for every user), the listing endpoint gains an opt-in?refresh=truequery param that bypasses BOTH the cloud cache and the 1-hour bundled-preset cache for that one call; the fresh result is still written back so subsequent normal callers still hit cached responses. New SliceModal "Refresh" button: lives in the preset section header next to the cloud-status banner, callsgetSlicerPresets({refresh: true})and writes the fresh slots into the['slicerPresets']cache viaqueryClient.setQueryData(so the spinner disappears immediately rather than triggering a second refetch). Spins theRefreshCwicon while in-flight; disabled during a slice enqueue so users can't fire it twice. i18n: real translations forslice.refreshPresets+slice.refreshPresetsTitle(action label + tooltip) across all 9 locales per the feedback_translate_dont_fallback HARD RULE; parity script green at 5007 leaves × 9 locales. Tests: 2 new backend intest_slicer_presets.py(refresh=Truere-hits Bambu Cloud even with a warm cache + still writes the fresh result back for the next normal call; same shape for_fetch_bundled_presets); 1 new frontend inLocalProfilesView.test.tsxasserts the delete flow invalidates['slicerPresets']in addition to['localPresets']via a spied QueryClient. Full backend suite + frontend vitest + ruff + eslint + i18n parity green. - STL thumbnail noise on first generation: matplotlib cache + font_manager scan (reported by @maziggy) — On first STL upload, three matplotlib-internal log lines surfaced:
WARNING [matplotlib] /opt/claude/.config/matplotlib is not a writable directory(Bambuddy's$HOMEisn't writable for the default config path so matplotlib fell back to/tmp/matplotlib-XXXXXX),INFO [matplotlib.font_manager] Failed to extract font properties from NotoColorEmoji.ttf(matplotlib doesn't support the COLR/COLR1 emoji format; this is per-font), andINFO [matplotlib.font_manager] generated new fontManager(the cache was rebuilt). Because the fallback was/tmp, every host reboot lost the cache and the font scan ran again. Fix is instl_thumbnail.pybefore the matplotlib import: (a)_configure_matplotlib_cache()setsMPLCONFIGDIRtosettings.base_dir / .cache / matplotlib(mkdir'd if missing) so the cache persists across container restarts and the writable-dir warning never fires; respects an externally-set value so operators who chose their own path aren't overridden; best-effort with a debug fallback if settings can't be imported or the mkdir fails. (b)logging.getLogger("matplotlib.font_manager").setLevel(WARNING)at module import demotes the per-font INFO scan so the first cold start (before the cache is populated) doesn't surface a multi-line matplotlib preamble. Tests: 3 new intest_stl_thumbnail.py— the font_manager logger is at WARNING after module import;_configure_matplotlib_cachecreates the directory underbase_dirand setsMPLCONFIGDIRto point at it; an externally-setMPLCONFIGDIRis preserved verbatim. - Bulk-upload ZIPs of stub / empty STL files no longer spam the log with thousands of warnings (reported by @maziggy) — Uploading a ZIP containing many minimal STL stubs (e.g. the 24-byte
solid test\nendsolid testshape) emitted oneWARNING [backend.app.services.stl_thumbnail] Failed to load STL or empty mesh: <path>per file. The warnings were technically correct —trimesh.load(...)returned a valid Mesh with zero vertices, the safeguard atstl_thumbnail.py:54matched, and the function returned None so the library entry got created without a thumbnail — but the volume turned a successful ZIP upload into a journal full of WARNING lines. Two-step fix: (1) the per-file message atstl_thumbnail.py:55demoted fromlogger.warningtologger.debug; this is a per-file content observation, not an actionable error, and the caller already handles None correctly. The branch now catches only the rare "large enough but trimesh still can't parse it" case, still visible in debug logs without spamming production. (2) New module constantMIN_USABLE_STL_BYTES = 200(binary STL with one triangle = 80B header + 4B count + 50B triangle = 134B; ASCII STL with one triangle ≈ 150B; 200 is a safe floor below any real STL). Three thumbnail call sites inlibrary.py(extract_zip_file ZIP entry path, single-file upload,_backfill_external_stl_thumbnails) pre-skip files below this size BEFORE callinggenerate_stl_thumbnail, so stubs / placeholders / corrupted files never enter the trimesh pipeline at all. What this does NOT change: behaviour is identical for any real STL — generation still runs, MAX_VERTICES still triggers simplification at 100k vertices for the 256×256 thumbnail render, large files still get thumbnails. Tests: 2 new intest_stl_thumbnail.py— one verifiesMIN_USABLE_STL_BYTESsits above the smallest binary (134B), the smallest ASCII (150B), and the reporter's 24-byte stub case; the other writes the verbatim 24-byte stub from the bug report, callsgenerate_stl_thumbnail, and asserts noWARNING-level "empty mesh" record appears incaplog. Full backend suite green; ruff clean. - Bambu Cloud sign-in failures caused by an upstream Cloudflare challenge now surface an actionable message instead of "Invalid response from Bambu Cloud" (#1575, reported by @cliveflint) — Reporter hit "Invalid response from bambulabs when trying to sign in with authenticator pass code" on a Pi (UK network). Log showed three back-to-back
POST /api/sign-in/tfacalls all returning Cloudflare's "Just a moment..." HTML interstitial instead of JSON;backend/app/services/bambu_cloud.py::verify_totpcaught thejson.JSONDecodeErrorand returned the opaque "Invalid response from Bambu Cloud" message. Root cause is Cloudflare-side, not Bambuddy: a curl from this machine with the same honestBambuddy/1.0 (+https://github.com/maziggy/bambuddy)UA at 2026-06-02 returned a cleanHTTP/2 400 {"code":5,"error":"Login failed"}JSON — same UA, same headers, different network. CF's bot management appears to flag conditions (per-IP / TLS-fingerprint / rate / transient mitigation window) that don't reproduce from us. No reliable way to prevent the challenge from our side without browser impersonation, which is explicitly off the table per the 2026-05-12 compliance audit. Fix is diagnostic, not bypass: new_detect_cloudflare_challenge(response) -> str | Nonehelper inspects the failed-parse response for CF markers ("Just a moment..."in body,"challenges.cloudflare.com"in body, HTTP 403 withcf-mitigatedheader, HTTP 503 withcf-rayheader) and returns a message that attributes the block to Bambu Lab's Cloudflare protection, suggests waiting a few minutes, and tells the user that signing in to bambulab.com from a browser on the same network usually clears the challenge. Wired into all three JSON-parse sites:login_request,verify_code, andverify_totp— previously onlyverify_totphad a defensive catch;login_requestandverify_codelet the parse error bubble toBambuCloudAuthErrorwith"Expecting value..."as the detail, which surfaced as a generic 401 in the UI. Tests: 8 new inTestCloudflareChallengeDetection(backend/tests/unit/services/test_bambu_cloud.py) — direct helper tests for each of the four CF markers, a negative case (real JSON 400 withcf-rayheader from the actual successful curl response above is NOT misclassified as a challenge so the application-level "Login failed" still surfaces), an attribution check (message must name "Cloudflare" and "bambulab.com" so users can act on it), and full-stack tests covering all three call sites with the verbatim interstitial fragment from the reporter's log. The existingtest_verify_totp_cloudflare_blockedupdated to assert the new actionable message. Full 5486-test backend suite green; backend ruff clean. - OIDC auto-provisioning now reads the standard
emailclaim forUser.emailwhenEmail Claimis set to a non-email identity claim (#1569, reported by @anderl1969) — Reporter configured Authentik withEmail Claim = preferred_usernameto drive username from the preferred_username claim and expected the standardemailclaim (which the ID token also carries) to populate the user's email field. Result: username was correctly set frompreferred_username, butUser.emailcame out empty. Cause:backend/app/api/routes/mfa.py::_resolve_provider_emailreads onlyclaims[provider.email_claim]. Withemail_claim="preferred_username"andpreferred_username="jdoe", the value fails the SEC-2 email shape check (no@) and returnsNone. The auto-create-users branch then constructsUser(email=None, …)and storesUserOIDCLink(provider_email=None)even thoughclaims["email"]carries a perfectly validjdoe@example.com. Fix: new helper_resolve_standard_email_for_user_record(provider, claims, provider_sub)reads the standardemailclaim independently and applies the same Fall A/B logic (shape check,require_email_verifiedstrict / permissive split, explicitemail_verified=Falsedrop). The auto-create-users branch inoidc_callbacknow resolvesuser_email_for_storage = provider_email or _resolve_standard_email_for_user_record(...)and uses that for bothnew_user.emailand theUserOIDCLink.provider_emailrecord. Scope is deliberately narrow: the fallback is invoked only whenprovider.email_claim != "email"AND the primary resolver returnedNoneAND the auto-create-users branch is taken. The auto-link-existing-accounts gate above remains on the primaryprovider_email— it does NOT consult the fallback. This preserves every existing GHSA-shape guard: Fall-B (email_claim='email'+require_email_verified=False) is still rejected at schema level when paired with auto-link; Fall-C (custom claim) auto-link still depends on the custom claim's shape, never on the standardemailclaim. New email fallback path runs the same shape +email_verifiedenforcement as Fall-A/B for the standardemailclaim, so an attacker-controlled IdP that setsemail_verified=Falseor sends a malformed value gets dropped exactly like it would on the primary path. Tests: 4 inTestOIDCStandardEmailFallback(backend/tests/integration/test_mfa_api.py) —email_claim=preferred_usernamewith both claims present → username frompreferred_username, email from standardemail;email_claim=preferred_usernamewith no standardemailclaim → email staysNone(behaviour unchanged); standardemailwithemail_verified=False→ fallback drops, email staysNone;email_claim="email"(default) withemail_verifiedabsent → fallback path does NOT fire (Fall-A semantics preserved). Full 5478-test backend suite green. Backend ruff clean. - Sliced
.gcode.3mffiles now render in the 3D preview and expose a Preview-3D action in the file row (#1543, reported by @Vlado-Tarakan) — Reporter exported a multi-plate.gcode.3mffrom Bambu Studio to the shared folder Bambuddy watches and the 3D preview tab came up empty; if he re-uploaded the same file via the file manager, the preview worked. Root cause: two paths classifyfile_typedifferently.backend/app/api/routes/library.py:1343-1348(the shared-folder scan path) does a compound-extension check and tags the filegcode.3mf; the upload path at the same file's1588does a singleext[1:]and tags it3mf. Thenfrontend/src/components/ModelViewerModal.tsx:71-73hadhasModel = normalizedType === '3mf' || 'stl'andhasGcode = normalizedType === 'gcode' || '3mf'— neither matchedgcode.3mf, so the capabilities object landed with both flags false and the modal rendered an empty bed.FileManagerPage.tsx:858also gated the Preview-3D context action onfile_type === '3mf' || 'gcode' || 'stl', so for shared-folder files the entry didn't even appear, and the type pill at765-770had no colour case forgcode.3mfso it fell through to the generic gray. Fix (frontend-only, no backend churn):ModelViewerModal.tsxintroduces anisThreeMfFamily = normalizedType === '3mf' || normalizedType === 'gcode.3mf'predicate used in two places — the capabilities branch (hasModel = isThreeMfFamily || 'stl',hasGcode = isThreeMfFamily || 'gcode') and the plates-loading branch that previously hard-gated on!== '3mf'and would have returnedsetPlatesData(null)for the shared-folder file.FileManagerPage.tsxaddsgcode.3mfto the Preview-3D action gate and shares the gcode blue type-pill colour so sliced-output files are visually distinguishable from source 3MFs. The compoundgcode.3mfclassification on the backend is intentionally preserved — it carries useful "this is a sliced output" semantics that other UI surfaces could use later. ThecanOpenInSlicerandsliceableTypechecks atModelViewerModal.tsx:269, 277-280are deliberately left alone — a sliced output isn't openable in the slicer, andsliceableTypealready explicitly excludes.gcodeand.gcode.3mfper the comment "the file type can't be sliced". Out of scope (separate Bambu-Studio format limitation, not a Bambuddy bug): Vlado's secondary observation that the upload-path 3D preview "shows only one plate" even though his project has 5 plates — Bambu Studio's.gcode.3mfexport contains the g-code and model data for the active plate only, not the entire multi-plate project. The print picker enumerates plates viagcode_*.gcodeentries inside the zip (a separate code path), which is why the user can still pick the plate at print time. The empty-bed fix is the data point that closes the user-visible bug. Tests: existing full 2043-test frontend suite green; no test asserted on the unsupportedgcode.3mfcapabilities branch (the change is additive —3mfandstlandgcodebehaviours are unchanged). Frontend build clean. - Connected-edge reconciliation closes the missed-PRINT-COMPLETE loop that produced ghost replays on smart-plug power cycles (#1542 follow-up, reported by @vixussrl-ui) — Reporter ran a fresh trace after the doubled-extension fix landed and found a distinct second cause behind his ghost prints, hitting 4-of-4 of his A1s. Timeline: 22:50 PRINT START → print runs all night → MQTT disconnects multiple times (A1's keepalives are unstable on his network) → print finishes during one of those disconnect windows so PRINT COMPLETE is never observed → smart plug cuts power on idle → power resumes for the next scheduled print → firmware auto-replays the leftover
.3mffrom the SD card → Bambuddy reconnects to a fresh PRINT START for the ghost. The existing IDLE-after-RUNNING completion check atbackend/app/services/bambu_mqtt.py:3022was meant to catch the simple disconnect-then-finish case via_previous_gcode_statepreserved across reconnects, but with multiple disconnect/reconnect cycles + a smart-plug power-off that Bambuddy can't distinguish from any other transient drop, the IDLE window that branch needs simply never reaches it. The SD.3mflingers, the firmware ghost-replays every power cycle, and the loop repeats until the operator notices. Fix: a new connected-edge reconciliation pass — newreconcile_stale_active_prints(printer_id)inbackend/app/main.pyqueries archives instatus="printing"for the printer at MQTT (re)connect time and synthesiseson_print_complete(status="aborted")for any whose print can't actually be running anymore. The decision is made by a pure_is_active_archive_stale(archive, state)function with three triggers: (1) current printer state is terminal (IDLE / FINISH / FAILED) — covers the clean disconnect-then-finish case the existing #3022 branch was already trying to handle; (2) printer is running but with a differentsubtask_idthan the archive — Bambu firmware mints a freshsubtask_idfor each print including the ghost-replay it runs after a power cycle, so a mismatch is unambiguous evidence the in-DB archive is no longer the print on the printer; (3) printer is running butsubtask_nameis empty — the printer doesn't know what it's running, archive reference is broken. PAUSE / PREPARE / SLICING / RUNNING with matching subtask are intentionally left alone — false positives there cost a single misreported "aborted" status that the real PRINT COMPLETE would have overwritten anyway, while a false negative is the ghost-print loop being reported. The synthesisedon_print_completereuses the existing chain (SD cleanup, status update, usage tracker, notifications) — no reimplementation, no duplicate event when real completion later fires (the second call seesstatus != "printing"and falls through). Status"aborted"is the conservative label; we have no progress evidence to promote to"completed". Wiring: new_printer_reconciled_since_connect: dict[int, bool]edge tracker at module scope, checked at the start ofon_printer_status_change— whenstate.connectedflips False → True (which covers both Bambuddy startup with no prior connection AND a mid-session MQTT reconnect), reconciliation fires exactly once for that connection. Setting the edge to True BEFORE the spawned task starts prevents concurrent status updates within the same connection from re-triggering it. Concurrency: reconciliation runs asasyncio.create_taskso it doesn't block the WebSocket dedup / broadcast logic that on_printer_status_change is the hot path for. Ghost-print collateral worth being explicit about: if the ghost is already running when reconciliation fires, the synthesised SD-cleanup will hit 550-file-locked (firmware locks the file during print, same cause as the #1542 first case). The cleanup retries 3× then logs "lingering" — same as any other in-print cleanup attempt. The ghost runs to completion, its own end-of-print cleanup deletes the file, and the next power cycle has nothing to replay. The loop breaks even when reconciliation can't physically delete the file mid-ghost. A perfect cancel would require sending aprint_stopMQTT command to the printer, which is invasive and explicitly out of scope. Tests: 21 intest_reconcile_stale_active_prints.py—TestIsActiveArchiveStalecovers all three stale triggers with case-insensitive state matching, the four healthy-no-op cases (RUNNING / PAUSE / PREPARE / SLICING with matching subtask), the IDLE-overrides-subtask-match precedence, and the missing-subtask_id edge cases that fall through to the subtask_name check.TestReconcileStaleActivePrintscovers the orchestrator: no-status, disconnected-status, and no-active-archives all short-circuit; a stale archive produces a synthesisedon_print_complete(status="aborted", _reconciled=True)payload with the archive filename; a healthy in-flight archive doesn't fire any completion; an exception inside one archive's synthesis doesn't block the rest or propagate to the caller. Full 5399-test backend suite green (5378 + 21 new). Backend ruff clean. - Fallback-archive MQTT filament extraction now actually fires for real prints (#1533 follow-up, reported by @JmanB52D) — Reporter updated to 0.2.5b1 expecting the #1533 fix to populate filament fields on his P2S virtual-printer prints when the .3mf is locked. His support bundle showed Bambuddy still creating fallback archives with NULL filament fields even though the print-start log line proved AMS-0-T0 had PETG loaded at the moment the helper should have read it (
AMS 0: T0(type=PETG, color=FFFFFFFF, …)). Cause: the #1533 helper_extract_filament_data_from_mqtt(data)inbackend/app/main.pyonly looked atdata["ams"], but the dict thaton_print_startactually receives at runtime is the wrapper shape{"filename", "subtask_name", "remaining_time", "raw_data": <mqtt_payload>, "ams_mapping"}thatbackend/app/services/bambu_mqtt.py:2971-2980constructs — sodata["ams"]was undefined on every real call and the helper silently returned{}, leaving the fallback archive'sfilament_type/filament_colorNULL. The 15 unit tests that shipped with #1533 all passed the bare inner shape directly and never exercised the callback wiring, so the regression slipped through the green build. Fix: the helper now resolvesdata["raw_data"]["ams"]first (the callback shape) and only falls back todata["ams"]when the wrapper isn't present (preserves the inner-shape callers from the existing tests). Defensive: a non-dictraw_data(e.g. partial MQTT decode failure) falls through to the inner lookup instead of crashing. Tests: 5 new inTestOnPrintStartCallbackShape(backend/tests/unit/test_fallback_archive_mqtt_filament.py) — wrapper payload with ams_mapping resolves to the inner data; wrapper with no ams_mapping lists all loaded slots; the existing inner-shape callers still work after the additive wrapper lookup; missingraw_datareturns{}instead of raising; junkraw_data(string) doesn't shadow a present innerams. Full 5378-test backend suite green. Backend ruff clean. What this does NOT fix: per-filament gram usage still needs the actual .3mf — the printer locks it during print (P-line firmware behaviour, not a Bambuddy bug), and the existing 19 FTP candidate paths + directory probes are expected to 550 in that window. Per-print filament type and colour are the data point that drives the AMS-expansion planning the reporter explicitly called out, so this is the fix that moves the needle for him. - Assigning a spool no longer shows a profile-mismatch warning when only the slicer profile differs, and the warning now states the AMS slot will be reconfigured (#1552, reported by @anthonyma94) — Reporter assigned a spool to a slot whose stored slicer profile (e.g. "Bambu PLA Matte") differed from the new spool's profile (e.g. "Bambu PLA Basic"), got a warning popup with only Cancel / Assign Anyway, and was under the impression that confirming the popup just linked the spool in Bambuddy's DB without touching the AMS — i.e. that he then had to manually open Configure AMS Slot to push the new profile to the printer. The auto-push has actually been in place since the assign route existed:
backend/app/api/routes/inventory.py::assign_spoolcallsapply_spool_to_slot_via_mqttafter upserting the SpoolAssignment row, which publishes bothams_filament_setting(tray_info_idx, tray_sub_brands, color, temps) andextrusion_cali_sel(K profile) over MQTT, andbackend/app/api/routes/spoolman_inventory.py::assign_spoolman_slotdoes the same on the Spoolman side. The only short-circuit is when the firmware explicitly reports the slot empty (tray_state ∈ {9, 10}), in which casemain.py::on_ams_changedeferred-replays the configure as soon as a spool appears. So the popup was creating friction without revealing what it actually did. Two changes: (1)AssignSpoolModal.tsx+spoolbuddy/AssignToAmsModal.tsxno longer fire the mismatch popup for profile-only mismatches —if (materialMatchResult !== 'exact')replaces the oldmaterialMatchResult !== 'exact' || !profileMatches, and the'profile'member is dropped from themismatchTypeunion (the standalone profile branch in both popup render bodies is removed as dead code). Material mismatch — where Bambu firmware can refuse the print because the type is wrong — still warns. (2) Every firing warning (material, partial, material+profile, partial+profile) now appends a new line via the newinventory.assignReconfigureNotei18n key: "The AMS slot will be reconfigured to use the spool's profile." This makes the Assign Anyway button's effect explicit instead of leaving users to guess. i18n: real translations across all 9 locales per feedback_translate_dont_fallback; parity script clean at 4999 leaves per locale. Tests: existing 14AssignSpoolModal+ 7AssignToAmsModaltests pass unchanged — no test asserted on the profile-only popup firing. Frontend build clean, full 2043-test suite green. Open follow-up: if anthonyma94 confirms after this change that his slot still shows the old profile after Assign Anyway, the real bug is inapply_spool_to_slot_via_mqtt's tray_info_idx / setting_id resolution for his specific spool shape — would need his spool'sslicer_filamentvalue plus the live tray state to diagnose. - Transparent / clear filament now selectable and rendered as transparent end-to-end in the built-in inventory (#1545, reported by @Synec5, confirmed by @CMW-ISS) — Reporter wanted to select a transparent filament colour in the spool editor; CMW-ISS independently confirmed on v0.2.5b1 that AMS-detected transparent spools were silently labelled "Black" in the filament-mapping dropdown because the colour name resolver dropped the alpha byte and the underlying RGB
000000HSL-bucketed to "Black". Spoolman already supported 8-digitRRGGBBAAhex; the built-in inventory didn't. Five distinct sites collapsed alpha → 6-char RGB and had to be fixed together: (a)frontend/src/utils/colors.ts—hexToColorName,getColorName,resolveSpoolColorName, andisLightColornow short-circuit to"Clear"when the input is 8 chars with alpha00, before either the catalog lookup or the HSL fallback can mislabel transparent as black;isLightColorreturnstruefor clear so text contrast matches the light/mid-gray checkerboard underlay the swatch paints. (b)frontend/src/utils/amsHelpers.ts::normalizeColorno longer unconditionally strips the alpha byte — it preserves#RRGGBBAAwhen alpha <FFso the AMS-side colour reaches CSSfill=/backgroundColoras a translucent value instead of a solid one; opaque colours still emit#RRGGBBandnormalizeColorForCompare(which DOES strip alpha) is unchanged so type/colour matching for auto-mapping is unaffected. (c)backend/app/api/routes/printers.py::get_available_filamentsno longer truncatestray_colorto 6 chars before emitting it on/printers/available-filaments— both the AMS andvt_traybranches now pass the full#RRGGBBAAthrough; the dedup key still uses the 6-char RGB so two slots that share an RGB but differ only in alpha still merge into one filament requirement. (d)frontend/src/components/spool-form/constants.tsgained a{ name: 'Clear', hex: '00000000' }entry toQUICK_COLORS— the only 8-char preset, because the native<input type="color">can't pick alpha and a dedicated swatch is the only UX that lets the user actually choose transparency. (e)frontend/src/components/spool-form/ColorSection.tsxreworked the hex draft contract: previously the hex input was hardcoded to 6 chars and every commit path unconditionally appended'FF', so even pasting00000000got truncated to000000FF(solid black). Now: the draft accepts up to 8 hex chars; a 6-char commit appendsFF, an 8-char commit passes through verbatim; on blur a 7-char draft (RGB + one alpha nibble) right-pads the nibble to0instead of jumping back to 6-char-pad-RGB; theselectColor()helper that the preset swatches call only appendsFFwhen the preset is 6 chars, so the newClearswatch lands as00000000informData.rgbainstead of00000000FF.currentRgbais canonicalised to 8 chars uppercase andisSelected()matches on the full rgba soClear(00000000) doesn't collide withBlack(000000FF) in the swatch highlight. (f) Two new shared helpers infrontend/src/utils/colors.ts—getSwatchStyle(rgba)returns a{ backgroundColor }for opaque colours and a{ backgroundImage, backgroundSize }8px checkerboard for alpha=00 (use for div / button backgrounds);spoolColorString(rgba)returns a hex string that preserves the alpha byte when alpha < FF (use for SVGfill=props and other single-string colour contexts where the consumer can interpret 8-char hex natively). Applied to every simple-swatch site that previously didstyle={{ backgroundColor: '#' + rgba.slice(0, 6) }}or passed a 6-char fill to an SVG icon — those sites would have rendered Clear spools as solid black after the cream rewrite was removed: the three preset rows inColorSection.tsx(recent / catalog / fallback), the spool checkbox swatch inLabelTemplatePickerModal.tsx, the per-card colour dot + the SVGSpoolCircleinSpoolBuddyInventoryPage.tsx, the assigned-spool indicators inSpoolBuddyAmsPage.tsx(both internal and Spoolman branches), the four selected-spool summary swatches + the simple-view's spool dot inSpoolBuddyWriteTagPage.tsx, the lead-spool indicator inForecastPanel.tsx, the header swatch inAssignToAmsModal.tsx, the two spool-list dots inAssignSpoolModal.tsx(internal + Spoolman columns), and theSpoolIconfed byInventorySpoolInfoCard.tsx/TagDetectedModal.tsx/SpoolInfoCard.tsx/LinkSpoolModal.tsx(which now pass the full 8-char rgba — SVGfill=interprets translucent values correctly).FilamentSwatch.tsx's tooltip title fallback also widened so the on-hover hex code shows#00000000for a Clear spool instead of misreporting it as#000000. What is intentionally NOT changed: the native<input type="color">value inSpoolBuddyWriteTagPage.tsx's simple-view picker keeps its 6-char hex — that input element doesn't support alpha, and its onChange handler still sets rgba back to opaqueFF(which is correct behaviour: the user explicitly picked a colour via the picker, not transparency). The colour-sort comparator inLabelTemplatePickerModal.tsx::colorSortKeykeeps its 6-char alpha-strip — transparent spools sort into the same bucket as black/neutrals which is the right behaviour for ordering. The label-renderer inbackend/app/services/label_renderer.pykeeps its 6-char alpha-strip in_hex_code_labelbecause the printed text on a physical label can't show transparency —_color_from_hexdoes honour the alpha byte for the printed swatch fill (alpha=00 → invisible swatch on the label, which is the honest physical answer). The Spoolman auto-sync's_find_or_create_filamentinbackend/app/services/spoolman.pystill strips alpha when looking up the Spoolman catalog because Spoolman's filament catalog schema only supports 6-charcolor_hex— a transparent AMS spool synced into Spoolman will now match against a000000(Black) Bambu Lab filament entry instead of the pre-fix synthetic "PLA Basic" cream entry (RGBF5E6D3); both are inaccurate, the post-fix behaviour is at least honest about which colour the catalog has chosen rather than silently inventing a cream spool — users on the Spoolman backend can manually correct the filament assignment if desired. (g) Removed the cream rewrite atbackend/app/services/spoolman.py::parse_ams_traythat silently replaced AMS-reported00000000withF5E6D3FF("Light cream/natural color"). That rewrite was a workaround from when the swatch renderer couldn't show alpha —filamentSwatchHelpers.ts::buildFilamentBackgroundalready paints a checkerboard underlay for alpha < FF (added in #1154), so the rewrite has been hidden technical debt that made every AMS-detected transparent spool land in inventory as cream instead of clear, with no signal to the user that a colour was substituted. AMS-synced spools now keep their true00000000value; the swatch renders the checkerboard;getColorNameresolves to "Clear". (h)backend/app/services/spool_tag_matcher.py::create_spool_from_trayshort-circuits the colour-catalog lookup whenrgbais alpha=00 and storescolor_name="Clear"directly — without this, a Bambu-RFID transparent spool would resolve against the#000000row in the catalog (orBlackvia the HSL fallback) before the frontend's name resolver ever sees it, defeating the alpha-aware fix incolors.ts. Tests —src/__tests__/utils/colors.test.ts: 5 new assertions covering alpha=00 → "Clear" forhexToColorName,getColorName(including precedence over a catalog entry on the same RGB), andresolveSpoolColorName; one existing assertion changed from12345600(which now correctly resolves to "Clear") to123456FFto keep its intent of "unknown opaque colour returns null".src/__tests__/components/spool-form/ColorSectionHexInput.test.tsx: header docblock rewritten to reflect the new 0–8 char draft contract; the "truncates 7–8 char pastes to RGB" test replaced with two new tests —'0011223344'paste now truncates to the leading 8 chars (00112233) and commits verbatim with noFFappend, and a 7-char draft on blur pads to 8 with a trailing0instead of jumping back to RGB. 17 colours tests, 9 hex-input tests, 53 useFilamentMapping tests, 14 FilamentOverride tests, 10 FilamentSlotCircle tests, 6/printers/available-filamentsintegration tests, 50 Spoolman API integration tests all green. Backend ruff clean; frontend build clean; i18n parity clean at 4998 leaves per locale. What this does NOT change: Spoolman-mode parity is preserved — Spoolman's own picker already supported 8-digit hex andinventory.py:119/spoolman.py:887-889already passed00000000through verbatim (the 6→8 charFFpad only fires whenlen == 6), so no parallel mutation is needed on the Spoolman-mode write path. Existing inventory rows that were already rewritten toF5E6D3FFstay as cream until the next AMS sync overwrites them — a one-time edit is the only path to recover them, and dropping the rewrite means future AMS syncs land the true value. - Virtual-printer MQTT no longer drops idle slicer connections at exactly 60 s (#1548, reported by @hollajandro) — Reporter pointed OrcaSlicer at a Bambuddy virtual printer and got a clean MQTT/TLS connect, successful auth, and a normal pushall/get_version exchange — then the slicer dropped exactly ~60 s later, every time, even after a fresh trust of the VP CA, a logged-out Bambu account, and toggling VP mode. Trace from his support bundle: 5 consecutive connect→disconnect cycles all exactly 60 s apart, with no intervening client packets after the initial exchange. Root cause:
backend/app/services/virtual_printer/mqtt_server.py::_handle_clientused a hardcodedtimeout=60on every per-packet read, and_handle_connecttwo functions below explicitly skipped the keepalive field from the CONNECT payload (# Skip keepalive/idx += 2). So no matter what the client negotiated, the VP server would close the socket after 60 s of silence — and OrcaSlicer's normal pattern after the initial exchange is to sit quietly waiting for the printer to push status updates, which a virtual printer with no real state changes doesn't do. The real Bambu firmware honours the client's keepalive (MQTT spec §3.1.2.10 / §4.4: server must allow 1.5× the negotiated value before disconnecting), which is why Orca works against a real P1S but failed at exactly 60 s against the VP. Fix:_handle_connectnow parses the 2-byte big-endian keepalive value from the CONNECT payload and returns it alongside the auth bool (tuple[bool, int])._handle_clientuses that to set its per-packet read timeout to1.5 × keep_aliveafter a successful CONNECT, orNone(no timeout) when the client opted out withkeep_alive == 0per spec. The 60 s default is retained for the initial read before CONNECT arrives, so a TCP-connect-but-never-send still gets reaped. Tests: 7 intest_vp_mqtt_server.py—TestHandleConnectKeepalive(4: returns negotiated value on success, returns 0 for opt-out, returns(False, 0)on auth fail / parse error so the caller's tuple-unpack never crashes),TestHandleClientHonoursKeepalive(3: idle client withkeep_alive=180is still alive past the old 60 s boundary;keep_alive=2closes idle in ~3 s; a PINGREQ inside the window resets the timeout and the connection exits via DISCONNECT instead of timeout). The integration-style tests feed a synthetic CONNECT into a realasyncio.StreamReaderand drive the handler on an event loop, so the timeout math is exercised end-to-end, not just unit-mocked. Backend ruff clean. - A1 no longer auto-replays the previous print after a power cycle when the library row's filename has a doubled
.gcode.3mf(#1542, reported by @vixussrl-ui) — Reporter has seven A1s powered through Tuya smart plugs + Home Assistant. After every plug-driven auto-off, turning the printer back on would sometimes start the previous print on its own. Trace from his support bundle: the library row in his DB hadarchive.filename = "Cube (1).gcode.3mf.gcode.3mf"— the.gcode.3mfsuffix had been appended twice somewhere during the file's import. The dispatcher'sarchive.filename→ SD-card-name derivation only stripped ONE trailing.gcode.3mf, so the upload landed at/Cube_(1).gcode.3mf.3mf. The print ran fine, but the post-print SD cleanup inmain.pyderived its delete target fromsubtask_name + ext(/Cube_(1).3mf,/Cube_(1).gcode) — neither matched the actually-uploaded path, both 550'd three times, and the real file lingered on the SD card. On next power-up the A1 firmware picked up the leftover .3mf at the SD root and started printing it, exactly like the P1S behaviour the original Issue #374 cleanup was meant to prevent. Two structural fixes, both shipped together (no follow-ups per feedback_no_followups): (1) shared name derivation. Newderive_remote_filename(filename)helper inbackend/app/utils/filename.pyiteratively strips trailing.gcode.3mf/.3mfsuffixes until the bare stem remains, then appends a single.3mfand underscore-replaces spaces (the firmware parsesftp://{filename}as a URL, spaces break it). Iterative strip handles the doubled-suffix data; the previous single-iteration strip silently fell through to "append .3mf to whatever's left", which is how doubled extensions ended up on the SD card in the first place. The helper is the single source of truth for the SD-card target name — three previously-duplicated upload sites now route through it:_run_reprint_archiveand_run_print_library_fileinbackend/app/services/background_dispatch.py, and the queue dispatch inbackend/app/services/print_scheduler.py. (2) cleanup uses the same algorithm as upload. The post-print SD cleanup inmain.pynow fetchesarchive.filenamewhenarchive_idis resolved and triesderive_remote_filename(archive.filename)FIRST, with the legacy/{subtask_name}.3mfand/{subtask_name}.gcodepaths kept as fallbacks for archive-less prints (subtask never matched any archive) and for older naming variants. De-duped when the primary target equals one of the fallbacks, so the happy-path delete count is unchanged. On the reporter's case the new primary candidate is/Cube_(1).gcode.3mf.3mf, matching the on-card file and deleting it cleanly — no more ghost print. Out of scope (separate concern): the upstream import path that produced the doubled.gcode.3mf.gcode.3mffilename is not addressed here — the iterative strip inderive_remote_filenamedefends against it everywhere it matters (upload target, cleanup target), so any future user with the same legacy data still gets clean dispatch and cleanup. Defensive hardening caught in the first integration run: the initial helper had no input type check, just awhile Truestrip loop withendswith/ slice. When a unit test mock (unittest.mock.MagicMock) was passed in by accident via the new cleanup path,mock.endswith(".gcode.3mf")returned a truthyMagicMockon every iteration and the slicestem[:-10]returned anotherMagicMock— the loop never reached theelse: breakbranch. Each iteration allocated a freshMagicMockuntil the LXC cgroup OOM-killer reaped the pytest worker at 61 GB anon-rss (visible injournalctl -kasoom_memcg=/lxc/109withCONSTRAINT_MEMCG). Fixed by adding anisinstance(filename, str)guard that raisesTypeErrorinstead of entering the loop — turns the silent infinite allocation into a loud, debuggable error. The same guard protects production: if a corrupt DB row or ORM edge case ever surfaces a non-strarchive.filename, the cleanup logs a warning via its outertry/exceptinstead of OOMing the backend. Tests: 10 inTestDeriveRemoteFilenameintest_filename_validation.py(single.gcode.3mfstrip, single.3mfstrip, bare stem appends.3mf, space→underscore, the literalCube (1).gcode.3mf.gcode.3mfreproducer from #1542 →Cube_(1).3mf, doubled.3mf.3mf, mixed.gcode.3mf.3mf, raw.gcodepreserved as.gcode.3mfsince.gcodealone is a valid sliced file, idempotence — running the helper on its own output is a no-op, Unicode stem preserved, type guard —MagicMock/None/intinputs all raiseTypeErrorwith a clear message instead of entering the loop). 315 dispatch + print-complete-path tests green (test_phantom_print_hardening.py,test_print_start_assigns_printer_id_to_vp_archive.py,test_print_start_expected_promotion.py,test_cost_tracking.py,test_print_queue_api.py'sTestAbortedStatusNormalisation— which was the suite that originally OOM'd, now passes in 2 s serial / 12 s under-n 30). Backend ruff clean. - Print filenames with FAT32-illegal characters now rejected at rename/upload/queue time instead of failing at FTP (#1540, reported by @anthonyma94) — Reporter could rename a library file to
L|R.3mf, and the PUT/library/files/{id}endpoint accepted it becauselibrary.py:4011only blocked/and\. The pipe (and the rest of the FAT32/exFAT-illegal set< > : " / \ | ? *, control chars, trailing dots/spaces) flowed through to FTP upload time, where the printer's SD card rejected the create with553 Could not create file— far from the rename action that caused it. Bambu Studio refuses these names client-side in its save dialog; Bambuddy now does the same. Fix: newbackend/app/utils/filename.pyexportingvalidate_print_filename(name)andInvalidFilenameError— single source of truth for the rejected set (Bambu-Studio-parity: the nine chars above, control codes 0x00-0x1F, empty/whitespace-only, bare./.., trailing space or dot, and 255 UTF-8 bytes max). Wired into three boundaries: (a)update_fileatlibrary.pyreplaces the path-separator-only check; (b)upload_fileatlibrary.pyrejects bad multipart-upload filenames before they're persisted; (c)print_library_fileadds a pre-flight check so older library rows that pre-date the rename validation fail with an actionable 400 instead of an obscure FTP 553; (d)add_to_queueatprint_queue.pysame pre-flight so queued files don't sit waiting just to fail at dispatch. The print/queue checks deliberately refuse rather than auto-rename — silently rewriting user filenames was the wrong UX (Studio doesn't, and the user explicitly chose that name). Existing rows with illegal names are left alone; users see a clear error pointing at rename. Frontend: the rename modal inFileManagerPage.tsxnow mirrors the same character set client-side, shows the offending char inline as a red error below the input, and disables the Rename button while invalid — matches Bambu Studio's instant feedback rather than a round-trip-to-400. i18n: newfileManager.invalidFilenameCharkey with real translations across all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW + en) per feedback_translate_dont_fallback; parity script clean at 4998 leaves per locale. Tests: 26 intest_filename_validation.py(parameterised over every char inINVALID_FILENAME_CHARS, the exactL|R.3mfreproducer from the bug, empty/whitespace/./.., control chars, trailing space/dot, byte-length cap with multi-byte UTF-8 to verify it's bytes not codepoints). Backend ruff clean; frontend build clean. - Fallback archives now carry MQTT-derived filament type + colour when the 3MF can't be downloaded (#1533, reported by @JmanB52D) — Reporter (lead of a maker-space 3D Fab area) was evaluating Bambuddy partly to count filaments per print for AMS expansion planning; print log was showing "—" in the filament column for every job. Trace: a P2S in VP proxy mode where the slicer's .3mf upload lands on the real printer's SD card, then the printer locks the file mid-print and refuses every FTP read (the existing fallback-archive code path in
main.py:2596, originally added for P1S/A1 printers, anticipates this: "FTP has file size limitations" — same effective behaviour on P2S). The user log shows ~12 FTP candidate paths attempted on every print start, every one returning 550, then directory listings on/cache /model /data /data/Metadataalso returning 550, then the fallback archive being created withfile_path=""and every filament column NULL — even though the MQTT print-start payload already had the AMS state and the slicer's slot-per-print-filament mapping sitting indata["ams"]["ams"]/data["ams_mapping"]. Fix: new_extract_filament_data_from_mqtt(data, ams_mapping)helper inbackend/app/main.py(placed next to the existing_get_start_ams_mapping) walksdata["ams"]["ams"][*].tray[*]to build a global-tray-id → (tray_type, tray_color) map, then narrows to slots referenced byams_mappingif present (slicer order preserved; -1 entries for VT-tray skipped), or falls back to every loaded slot otherwise. Output is a comma-separatedfilament_type+filament_colorin the same shape the 3MF extractor produces — so the inventory page, Quick Stats filament rollup, andlen(filament_type.split(','))per-print count all light up identically for fallback rows. Truncated to the model's column limits (50 / 200). Defensive against malformed MQTT shapes (non-dict entries, non-int ids, missing fields) since this runs in the print-start hot path and a raise would break print logging entirely. The fallbackPrintArchive(...)constructor now passesfilament_type=/filament_color=from the helper. What this is NOT: not per-filament gram usage (that needs the 3MF'sslice_info.configor a deep AMS layer-delta integration viausage_tracker) — only types and colours. The user explicitly asked for "the number of filaments used to know if or when we need to expand AMS units", which is exactly what this gives them (SELECT COUNT(DISTINCT split(filament_type, ',')) ...or the existing inventory count surfaces). A separate, larger piece of work to capture the .3mf in VP proxy mode at upload time (by sniffing FTP STOR intcp_proxy.py) is the real long-term fix for any user who wants full 3MF-derived archive metadata in proxy mode; it's not bundled here. Tests: 15 intest_fallback_archive_mqtt_filament.py(backend/tests/unit/) covering: empty / malformed / no-loaded-slot payloads return{}; the no-mapping path lists every loaded slot in ascending global-id order with colours uppercased; anams_mappingfilters to and reorders by the slicer's order; VT-tray sentinels (-1) are filtered; dual-AMS layouts resolveunit*4 + traycorrectly across units; a mapping pointing at unknown slots falls through to the known subset, but an entirely-unknown mapping returns{}rather than misreporting from the all-slots fallback; both column-limit truncations enforced; missing-colour-but-present-type emitsfilament_typeonly; defensive against non-dict/non-int garbage in the AMS list without raising. Existing 22 print-start unit tests untouched and green. Backend ruff clean. - SpoolBuddy: Tare status banner no longer sits at "Waiting for device..." forever (#1536, reported by @flom89) — On the SpoolBuddy kiosk's Settings → Scale (Waage) tab, pressing TARE wrote the "Tare command sent. Waiting for device..." banner but had no mechanism to resolve it. The daemon writes back through
POST /spoolbuddy/devices/{id}/calibration/set-tare(which stampstare_offset+last_calibrated_aton the device row), the device list query already polls every 10 s, buthandleTareinfrontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsxwas set-and-forget — the banner persisted indefinitely. The "Calibration complete!" banner on the full calibration flow had the same shape and stayed forever too. Fix: a completion watcher that snapshotsdevice.last_calibrated_atwhen TARE is pressed, sets anawaitingTareSincestate, invalidates the device-list query every 1 s while that state is active (so detection responds within ~1 s instead of waiting on the 10 s background poll), and whenlast_calibrated_atadvances past the snapshot flips the banner to "Tare complete!" with a 3 s auto-dismiss timer. A 15 s timeout on the watcher fails open to "Tare timed out — is the SpoolBuddy daemon running?" so a dead daemon doesn't leave the user staring at the spinner. The Calibration-complete success banner and the calibration-failed error banner now share the same auto-dismiss helper (3 s success, 5 s error). All timers are owned by auseRefthat cleans up on unmount; pressing TARE while a previous dismiss is queued cancels the old timer. i18n: two new keys (spoolbuddy.settings.tareComplete,spoolbuddy.settings.tareTimedOut) translated into all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW + en) per feedback_translate_dont_fallback — no English fallbacks. Parity script passes at 4997 keys × 9 locales. Frontend build clean. - ntfy notifications: honest User-Agent + actionable error when the server is behind a Cloudflare challenge (#1534, reported by @apizz) — Reporter pointed an ntfy server behind a Cloudflare Tunnel at Bambuddy and got
HTTP 403: <!DOCTYPE html>...Just a moment...on every Test click. They reproduced the same response with a plaincurl -H "Authorization: Bearer <token>" -d "test" https://ntfy.example/<topic>— confirming the 403 originates from Cloudflare's JS challenge intercept (Bot Fight Mode / "Under Attack" mode), not from Bambuddy or ntfy. Cloudflare returns its interstitial HTML to any non-browser client at the edge, so the request never reaches the user's ntfy backend at all. Bambuddy can't solve a JS challenge from a backend — the only real fix is on the user's Cloudflare side (a security-skip rule for the hostname/path, disabling Bot Fight Mode for that hostname, or fronting the server with Cloudflare Access using a service token). Two improvements shipped to make this footgun self-diagnosable for the next user who hits it. (1) Honest User-Agent on the notification HTTP client.backend/app/services/notification_service.pywas the one outbound httpx client in the codebase that didn't set the project-standardBambuddy/1.0 (+https://github.com/maziggy/bambuddy)UA — it leakedpython-httpx/<version>instead. Brings it in line withbambu_cloud/makerworld/firmware_check/inventory(all unified during the May 2026 compliance pass) and makes Bambuddy a more obvious citizen to upstream WAFs and proxy operators. Won't defeat Cloudflare's JS challenge (the user's curl test proves CF blocks regardless of UA) but it's a consistency / hygiene fix with no regression risk. (2) Cloudflare-challenge detection on the ntfy error path. New_looks_like_cloudflare_challenge(response)helper checks the response shape (Server: cloudflareorcf-mitigatedheader, or<!DOCTYPE html>...Just a moment...body). When a 403/non-success response matches, the error returned to the UI now reads: "HTTP 403 — ntfy server is behind a Cloudflare challenge. Bambuddy was served the JS challenge page instead of reaching ntfy. Cloudflare cannot be solved from a backend; add a Cloudflare security-skip rule for this hostname, disable Bot Fight Mode, or front the server with Cloudflare Access using a service token. (#1534)" — actionable, points at the real fix, removes the raw HTML dump. A regular 403 (e.g. ntfy auth failure with a plainforbidden: invalid auth tokenbody) still surfaces the original body so genuine auth errors stay debuggable; the interceptor only fires on the Cloudflare shape. Tests: 3 new inTestNtfyOutboundintest_notification_service.py— (a) the lazy-constructed httpx client carries the honest UA header on first use; (b) a 403 withServer: cloudflare+Just a moment...body produces the actionable error and does not echo<!DOCTYPEto the user; (c) a 403 with a plain text auth-failure body keeps the originalHTTP 403: forbidden: invalid auth tokenso we don't hide real errors. 110/110 in the notification suites green underpytest -n 30. Backend ruff clean. - Source-3MF upload on "fallback" archives no longer crashes with HTTP 500 (and stops orphaning files outside the data volume) (#1531, reported by @d3nn3s08) — When MQTT reports a print start but Bambuddy never saw the source 3MF (cloud-initiated prints, Bambu Handy, prints already on the printer's SD card when Bambuddy connected),
main.py:2596creates a "fallback"PrintArchiverow withfile_path="". The twoArchives → Source 3MF Uploadroutes computed the destination directory as(settings.base_dir / archive.file_path).parent / "source"— which on a fallback row collapsed toPath('/app/data') / '' = Path('/app/data'), whose.parentisPath('/app'), sending the upload to/app/source/<filename>.3mf. The file was physically written there (a path outside the user's mounted data volume — orphaned on container restart) and only the finalsource_path.relative_to(settings.base_dir)raised, so every retry left another orphan. Affected reporter is on a QNAP Docker host with the standard/app/datamount; both maintainer and triage initially diagnosed it as a Docker volume misconfiguration, but the traceback shows the bug is purely on Bambuddy's side — the user's setup was correct. Fix: new private helper_resolve_source_3mf_path(archive, source_filename)inbackend/app/api/routes/archives.pycentralises the destination computation. Normal archives still nest the source under<archive_file_dir>/source/<filename>. Fallback archives (emptyfile_path) now land under<base_dir>/archive/no_source/<archive_id>/<filename>instead — a deterministic, addressable location that stays inside the data volume, and the existing read sites (download_source_3mf,download_source_3mf_by_filename, the slicer-token routes,delete_source_3mf) all continue to work because they read back viasettings.base_dir / archive.source_3mf_path. The helper also defensively asserts the resolved directory is insidebase_dir.resolve()regardless of where it came from, so a row corrupted by an old import or a manual SQL edit fails with a clear 500 message ("Archive N resolves to a path outside the data directory; cannot attach source.") instead of silently writing outside the volume. Both upload sites (upload_source_3mfandupload_source_3mf_by_name, the slicer-post-processing endpoint) now route through the helper, so neither can independently drift back into the bug. Tests: 2 new inTestUploadSourceThreeMFinbackend/tests/integration/test_archives_api.py— (a)test_fallback_archive_source_upload_lands_under_base_dircreates an archive withfile_path="", uploads a minimal valid 3MF, asserts 200 status, that the returnedsource_3mf_pathis relative (not/app/source/...), that the file physically exists under the patchedbase_dir, and that the path is the deterministic fallback location keyed offarchive.id; (b)test_normal_archive_source_upload_unchangedis the same flow against an archive with a populatedfile_path, asserting the existingarchives/test/source/<filename>.3mflayout is preserved (regression guard against the helper accidentally changing the normal path). 57/57 intest_archives_api.pygreen underpytest -n 30. Backend ruff clean. Note: existing orphan files at/app/source/<filename>.3mffrom prior failed retries inside an affected user's container can be safely deleted; they were never indexed in the DB, never reachable from the UI, and would have vanished on the next container restart anyway. - SpoolBuddy weight sync no longer silently lands on a stale local row when Spoolman is enabled (#1530, reported by @chesterakl) — Reporter (Spoolman mode, H2C, internal "manually add then NFC-link" flow) saw the SpoolBuddy "Sync Weight" button flip to "Synced!" but the Spoolman-backed inventory listing never updated. Cause:
POST /spoolbuddy/scale/update-spool-weight(backend/app/api/routes/spoolbuddy.py) ran the lookup local-DB-first and only fell through to Spoolman on local miss — but the upstreamnfc/tag-scannedroute is exclusive (always-Spoolman whenspoolman_enabled=true, after the #1119 / nfc-routing fix). When the user's local DB still held a staleSpoolrow that happened to share a numeric id with the Spoolman spool the NFC tag mapped to, the sync endpoint absorbed the update into the stale local row, returned 200 with the localweight_used, and the actual Spoolman spool went untouched. The support log confirms it: 17 sync attempts across two days, every line loggedSpoolBuddy updated spool 2 weight: …g on scale, …g used(the local-branch log format) and theSpoolBuddy updated Spoolman spool …line (which only fires in the Spoolman branch) never appeared. The bug couldn't be reproduced on developer setups because they don't carry a leftover local row with a colliding id. Fix:update_spool_weightnow routes exactly likenfc_tag_scanned—_get_spoolman_client_or_none(db)first, and that result picks the branch exclusively. Spoolman mode goes straight to Spoolman with no local-DB read; local mode does the local update and returns 404 (not "fallback to Spoolman") on a local miss. Matches feedback_inventory_modes_parity — the two inventory modes must behave identically from the user's perspective, including which row gets written. The docstring now spells out the routing contract so the next reader doesn't reintroduce the local-first read. Tests: 1 new regression test inTestUpdateSpoolWeightSpoolman.test_stale_local_row_does_not_shadow_spoolman— creates a localSpoolwith the same numeric id as a mocked Spoolman spool, posts the sync, asserts (a) Spoolman'supdate_spoolwas called with the correct remaining weight, and (b) the local row'sweight_usedandlast_scale_weightare unchanged after arefresh()against the live DB. The existing 8 tests in that class continue to assert the Spoolman branch math (filament/spool-level tare priority, 404 / 503 mappings, 250g fallback warning). 9/9 green; 126/126 across the spoolbuddy + spoolman-filament-patch integration suites green underpytest -n 30. Cleanup hint for affected users: anyone in Spoolman mode with leftover local Spool rows from before they switched should delete those rows — they're inert under the new routing, but they were eating sync attempts under the old. Backend ruff clean. - Paused prints no longer inflate maintenance hours (#1521, reported by @TempleClause) — The
track_printer_runtimebackground task inbackend/app/main.pycounted bothRUNNINGandPAUSEstates equally towardruntime_seconds, which feeds every hours-based maintenance interval (lubricate rods, clean nozzle, check belts, etc.). Maintenance items measure mechanical wear, and pause time involves no motion — so a print paused overnight stretched the maintenance clock forward by ~8 h without any actual wear, triggering "lubricate rods" warnings earlier than warranted. Reporter found this by code review (no support bundle), flagged it cleanly with the exact line inmain.pyand three ranked solution options. Fix: option 1 (exclude PAUSE entirely) —state.state in ("RUNNING", "PAUSE")→state.state == "RUNNING". PAUSE now follows the same path as FINISH / IDLE / PREPARE: the elapsed-time accumulator skips it, andlast_runtime_updateis cleared so a later RUNNING transition starts fresh and doesn't back-bill the pause. No setting / toggle (reporter's option 3 was deliberately the throwaway — this is a wear-tracking semantic, not a user preference); no cap (option 2) — wear during pause is zero, not "reduced". Docstring and field-comment trail updated acrossmain.py,models/printer.py:23, and the twoapi/routes/maintenance.pyroute docstrings that all previously described the field as covering "RUNNING and PAUSE states". Out of scope: retroactive backfill of existingruntime_secondsvalues — already-accumulated pause time cannot be split out, only future accumulation is fixed. Users with hours-based maintenance intervals already set will see slower accumulation going forward (the correct outcome), so a previously-near-due item may take longer to ring than under the old behaviour. Tests: 3 new intest_runtime_tracking_pause.pypinning the new contract — PAUSE does NOT accumulate and clearslast_runtime_update; RUNNING still accumulates and updates the timestamp; a non-active state (FINISH) clearslast_runtime_updateto prevent back-billing the idle time when the printer next goes RUNNING. The tests drive the actualtrack_printer_runtime()coroutine through a single iteration via patchedasyncio.sleepagainst an in-memory SQLite DB, so they catch any regression in the predicate at the call site (not just an extracted helper). Backend ruff clean; targeted 24-test rod/runtime subset all green. - Quick Stats: user-cancelled prints now have their own bucket and no longer drag down the Success Rate gauge (#1390 follow-up, reported by @IndividualGhost1905) — Reporter saw
Total prints: 20 / Success: 18 / Failed: 1and asked where the 20th print went; the breakdown only showed Successful + Failed, so a cancelled run silently inflated the total without appearing anywhere. The earlier #1390 round had committed a test that locked in the bug —it('uses total_prints as denominator so cancelled/stopped events count')asserted the gauge should divide bytotal_prints, which lumped user/queue-cancelled jobs in with quality outcomes and conflated user intent with printer performance. Cause:PrintLogEntry.statushas six values in production (completed,failed,aborted,stopped,cancelled,skipped) but the Quick Stats endpoint inapi/routes/archives.pyonly counted two —completed→ Successful,status == "failed"→ Failed — and used a rawcount(*)for Total Prints, so the other four statuses ended up in Total without surfacing in any breakdown row.abortedwas particularly silent: classified as a failure elsewhere in the codebase (failure_analysis.py,main.py:430,1729) but not counted towardfailed_printsin stats. Fix: three-bucket classification across the whole stats surface, matching how the rest of the codebase already groups these statuses. Quick Stats now returnssuccessful_prints(completed),failed_prints(failed + aborted — printer-detected quality failures), and a newcancelled_prints(stopped + cancelled + skipped — user/queue interruptions). The SuccessRateWidget gauge divides bysuccessful + failedonly, so cancelling a roll because you changed your mind doesn't ding the printer's success rate — a Cancelled row in the breakdown surfaces the count so it doesn't silently vanish from Total Prints. The Failure Analysis service applies the same denominator change (failure_rate = failed / (successful + failed)) to both the headline rate and the per-week trend, so a week with no failures but several cancellations no longer reads as a misleading 0/N. Schema change is additive-safe:ArchiveStats.cancelled_printsdefaults to0so any historical fixture validating against the model still parses; the frontend type also defaults the display to0when the field is missing. i18n: newstats.cancelledkey with real translations across all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW) per feedback_translate_dont_fallback; parity script clean at 4994 leaves per locale. Tests: existingit('uses total_prints as denominator …')test inverted to assert the new behaviour (40 completed / 20 failed / 35 cancelled → gauge shows 67%, Cancelled row reads 35),cancelled_prints: 0added to the shared mock so the unchanged-display assertion (140/150 → 93%) still holds since140 / (140 + 10) = 93.33%rounds identically. 33 StatsPage tests + 6 backend stats/failure tests green; frontend build + backend ruff clean. Follow-up (cosmetic): the new Cancelled row's Ban icon rendered intext-bambu-graywhile the Successful and Failed icons used semantictext-status-ok/text-status-errortokens — reporter (@IndividualGhost1905) noted the asymmetry and asked for an orange to match what Archives + notification badges use for cancelled. Switched the Cancelled row totext-status-warning(amber-500, same token family as the other two rows), so all three icons are now semantic-token-driven and the new row matches the colour the user already associates with cancelled status elsewhere in the UI. - VP queue mode no longer blocks BambuStudio Send while the target printer is mid-print (#1558, reported by @phieb) — Reporter set up a non-proxy queue-mode VP with a target printer bound, started a print on the real printer, then tried Send to the VP from BambuStudio — slicer refused with the "busy" pre-flight error even though Bambuddy's whole job is to look idle so jobs queue any time. Cause traced by reporter:
SimpleMQTTServer._send_status_reportforcesgcode_state=IDLEand storage indicators on top of the cached-as-base mirror — good — but the cached branch overrode only a handful of fields, and the live print-progress fields from the mirrored realpush_status(mc_print_stage, mc_percent, mc_remaining_time, stg, stg_cur, layer_num, total_layer_num, print_error) passed through unchanged. The VP emitted a contradictory report (gcode_state=IDLE but mc_percent>0, stg_cur>0, ...) and BambuStudio's Send pre-flight read it as busy. Without a bound target the synthetic-stub branch reported all of these idle and Send worked — isolating the leak to the cached branch. Fix: in the cached branch, also override those 8 activity fields to the idle values the synthetic-stub branch uses (mc_print_stage="",mc_percent=0,mc_remaining_time=0,stg=[],stg_cur=0,layer_num=0,total_layer_num=0,print_error=0). Same shape as the #1228 storage-indicator overlay — internally consistent with the forced IDLE state while AMS / version / temperatures keep mirroring. Behavioural caveat for users: a slicer connected to the VP just for monitoring no longer sees the real printer's mid-print progress through the VP (since the cached push now reports idle). The real printer's IP / UI remains the source of truth for progress. Per the issue intent, this trade-off is explicit. Tests: newtest_live_progress_fields_zeroed_in_cached_branchintest_vp_mqtt_bridge.py::TestStatusReportCachedAsBase. - VP
_pending_files/ temp-file leak on every error path across the three file handlers — Pre-fix:_archive_file,_queue_file, and_add_to_print_queueonly popped_pending_filesand unlinked the temp file on the success branch. When archival failed (DB outage, ArchiveService raise, queue insert error), the entry stayed in the dict — and since the FTP layer keys its "same-name STOR already in flight" guard on filename, the slicer's next retry was spuriously rejected; the upload_dir also accumulated orphan temp files indefinitely. Each handler now uses atry / finallythat pops the marker and unlinks the temp file regardless of whether the body succeeded. 3 unit tests intest_virtual_printer.py::TestVirtualPrinterInstance(one per handler) inject a failure mid-flight and assert both invariants. - VP queue position now picks
MAX(position)+1instead of hardcoded1— Pre-fix: VP-uploaded queue items always landed atposition=1. With non-empty queues this created duplicate position=1 rows; the scheduler orders by(printer_id, position)so ties resolved in undefined DB-internal order, and repeat VP uploads accumulated multiple position=1 rows — making the queue's visible ordering non-deterministic and dispatching out of the user's intended sequence. Now the VP path runs the sameSELECT MAX(position) FROM print_queue_items WHERE printer_id=<target or NULL> AND status='pending'query the canonicalPOST /print-queue/route uses and inserts atmax_pos + 1. Defensivetry/exceptaround the.scalar()call so a mocked DB in tests can't cause aTypeErrorfrom MagicMock arithmetic. 1 unit test pins the MAX+1 behaviour (withMAX=7the inserted item lands atposition=8). - VP DELETE route cleans orphan
PendingUploadrows + on-disk upload_dir — Pre-fix:DELETE /virtual-printers/{vp_id}stopped the running instance and removed the row, but thebase_dir/uploads/<vp_id>/directory and anyPendingUploadrows that referenced it lingered. The user only learned the rows were orphaned by trying to archive one and getting a "file missing" → flip-to-discarded auto-handler — not exactly a clear signal. Now the DELETE handler queriesPendingUploadrows whosefile_pathstarts with the VP's upload_dir prefix, marks themstatus='discarded', thenshutil.rmtrees the directory after the DB commit succeeds (so a crash between commit and rmtree leaves orphan files at worst, not orphan rows pointing at a missing tree). 2 unit tests intest_vp_delete_cleanup.pycover the cleanup-with-orphans + clean-no-op paths. - VP
MQTTBridge._refresh_loopcrash no longer leaks the raw_message_handler — Pre-fix: if any exception escaped_resolve_client(the IP-encoding branch was the most likely culprit),_refresh_loopcaught it withlogger.exceptionand returned. The task completedstatus=done— not cancelled, not raising — sostop()never ran and_unbind_clientnever fired.self._on_printer_rawstayed registered on the liveBambuMQTTClientand kept reading / writingself._latest_print_stateon every real-printer message even though the VP bridge was functionally dead, creating a behaviour leak that persisted across VP restart. Now the crash exit explicitly calls_unbind_client()so the orphaned handler is detached even when the loop dies abnormally. - VP
sync_from_dbserialised byasyncio.Lock(concurrent-PUT race) — Two simultaneousPUT /virtual-printers/{id}calls (e.g. browser racing the auto-save trigger) could race the inner start/stop sequence and leave duplicate sub-services bound to the same port — split-brain state that only resolved on the next Bambuddy restart.VirtualPrinterManager.__init__now holds a_sync_lock;sync_from_dbwraps the body inasync with self._sync_lock. Single VP updates still complete in well under a second, so the serialisation isn't visibly slower. - VP
_slicer_print_optionscache bounded at 128 entries with FIFO eviction — Pre-fix: the dict that stashes the slicer'sproject_fileoptions (so_add_to_print_queuecan inherit timelapse / bed_leveling / flow_cali / etc.) had no bound. If the slicer sentproject_filefor a filename whose FTP upload was rejected / cancelled / non-3MF, the stash was orphaned and the dict grew one entry per such event for the VP's entire uptime. The new bound triggers eviction of the oldest entry once 128 entries accumulate. - VP
MQTTBridgesticky-key carry-forward now usescopy.deepcopy— Pre-fix: a sticky key carried over from the previous cache was assigned by reference, sharing nested dicts/lists between the old and new state. No current code path mutates a carried-forward sticky key in place, so this was latent — but a future merge that did would corrupt both copies. Defensivecopy.deepcopyon the carry-forward removes the foot-gun without changing observable behaviour. - VP
MQTTBridge._refresh_loopandSimpleMQTTServer._send_status_reportcached-path use deepcopy —_send_status_reportcached branch was usingdict(cached)— a shallow copy. Today's mutations are top-level only, but a future override that wrote into a nested dict (e.g.online,upgrade_state,ipcam) would corrupt the bridge cache and be read by every subsequent subscriber until the next real-printer push landed. Switching tocopy.deepcopyremoves the foot-gun. - VP
SlicerProxyManagerlifecycle hardening — Multiple proxy-mode fixes shipped together: (a)_ftp_data_proxiesand_actual_ftp_portare pre-initialised in__init__instead ofstart(), sostop()called beforestart()finishes (rapid mode-switch race) no longer raisesAttributeErrorand leaves sockets stranded; (b)_actual_ftp_portnow tracks the iptables-redirect target when the deployment usesREDIRECT --to-portto let non-root containers serve on 990, andget_status()returns it — diagnostic was previously probing the class constant 990 and false-failing on every working redirect deployment; (c) the FTP-data-proxyauto_closetasks (101 of them inFTPTLSProxy) are now tracked on_auto_close_tasksand cancelled instop()— previously they lingered ~60 s holding server references and could fail the next start with "address already in use"; (d) probe serversawait server.wait_closed()on stop instead of justsrv.close()— same rapid-restart race. - VP diagnostic now probes both bind ports 3000 and 3002 — Pre-fix: non-proxy bind diagnostic only probed 3002. The bind server in server mode actually listens on both (plain on 3000, TLS on 3002 per
bind_server.py:BIND_PORTS); a VP whose plain listener failed to start but TLS listener succeeded would pass the diagnostic while being half-broken. Nowport_bindreportspassonly when both probes succeed. NewPORT_BIND_PLAIN = 3000constant. - VP FTP
stop()awaits cancelled sessions instead ofsleep(0.1)— A session mid-write, mid-TLS-handshake, or holding a 60 s data-read could easily outlive the 100 ms sleep, and the server'sclose()would run while underlying sockets were still in use. Nowstop()cancels each session task andasyncio.gathers them withreturn_exceptions=True. Stop is a few ms slower in the typical case; worst-case bounded by whatever asyncio takes to propagate cancellation. - VP child sub-services (FTP / MQTT / Bind / SSDP) expose
readyevent for accurateis_running— See Added section for full description. - VP per-VP TLS certificate auto-regenerates when the shared CA is rotated — Pre-fix:
ensure_certificatesonly checked that the per-VP cert file existed. When the shared CA was regenerated (its expiry within 30 days), per-VP certs on disk were still signed by the OLD CA — slicers that imported the NEW CA failed handshake. The check is now a real signature verification:ensure_certificatesloads the on-disk per-VP cert and the on-disk CA, and verifies the cert's signature against the CA's public key viacryptography.hazmat.primitives.asymmetric.padding.PKCS1v15. OnInvalidSignature(rotation detected), the per-VP cert is regenerated under the current CA. The unit-test driven a real bug in an earlier version of this fix: comparing Subject DN was insufficient because Bambuddy's auto-generated CAs share the same Subject Name ("Virtual Printer CA"), so DN-match returned True even after rotation. 3 tests intest_vp_certificate_rotation.py(reuse-when-issuer-matches, regen-when-rotated, no-CA-returns-False). - VP
tailscale.py::get_statusnow catchesasyncio.TimeoutError— Pre-fix:_run_tailscalecould re-raiseTimeoutErrorafter killing a stuck subprocess. Theexcept OSErrorclause inget_statusdidn't catch it, so the exception propagated all the way to the FastAPI route handler and crashed the VP management UI for any user whose hosttailscaledwas lagging. Now the except clause covers bothOSErrorandasyncio.TimeoutError, returning aTailscaleStatus(available=False, error=...)either way. - VP
certificate.pyCA save uses correct parent directory — Pre-fix:_get_or_create_cacreatedself.cert_dir(the per-VP subdirectory) before writing the CA, but the CA writes targetself.ca_key_path.parent(the shared CA dir — potentially a different path). Latent because the manager pre-creates both directories; surfaced by the path-correctness audit. - VP
_extract_plate_idlogs failures at debug instead of silent — Pre-fix:except Exception: return Noneswallowed any failure to parseMetadata/slice_info.configwithout a log. A malformed 3MF then produced a wrong-plate dispatch with no diagnostic trail. The except now logs at debug so support bundles capture the parse error.
[0.2.4.4] - 2026-05-30
Security
- Fail-open authentication bypass on database errors — unauthenticated access to every protected endpoint during a forced DB-exception window (GHSA-6mf4-q26m-47pv, CVSS 9.8 critical, reported by @wondercrash) — Two functions in the auth path caught every exception and returned the "allow" answer instead of denying the request:
is_auth_enabledinbackend/app/core/auth.py:473(returnedFalse, treating "DB query raised" as "auth is disabled") and the globalauth_middlewareinbackend/app/main.py:5590(caught everything and calledawait call_next(request)with a comment that explicitly said "fail open for DB issues"). An attacker who could trigger any exception during the auth probe — the reporter's documented PoC floods/api/v1/auth/loginuntil the process exhausts its file-descriptor budget and the next SQLiteconnect()raises — could then hit any protected endpoint during that fail-open window with no token. CWE-636 (Failing Open) / CWE-755 (Improper Handling of Exceptional Conditions). Affected versions>= 0.1.6. Impact during the window: create a persistent admin account or API key, download the database backup (hashed passwords + encryption keys + printer access codes + MFA secrets), read/modify settings, control printers. Fix:is_auth_enablednow only returnsFalsefor the legitimate "settings row absent" case (scalar_one_or_none()returnsNone→ system was never configured for auth); any actual exception propagates so the caller can deny the request.auth_middlewarereturns503 Service Unavailableon any probe failure instead of letting the request through. The principle applied throughout: a failure to verify the auth state means the request is denied, not granted. Regression tests inbackend/tests/unit/test_auth_fail_closed.pypin the four contracts:is_auth_enabledpropagates DB exceptions, returnsFalsefor the no-row case, returnsTrueforvalue=true, returnsFalseforvalue=false. An existing security test (test_security.py::test_status_returns_500_on_db_error) was renamed totest_status_returns_503_on_db_errorand updated to accept either 500 or 503 (both fail-closed) while explicitly verifying the SQLAlchemy detail string doesn't leak in the response body. Codebase audit: grepped everyexcept Exceptioninbackend/app/core/auth.pyandbackend/app/core/permissions.pyfor the same shape;_validate_api_keycatches but returnsNonewhich leads to a 401 downstream (fail-closed),is_advanced_auth_enabledinbackend/app/api/routes/auth.pyalready propagates correctly,permissions.pyhas no catch-alls — no other auth-decision predicate carries this anti-pattern.
[0.2.4.3] - 2026-05-24
Added
- SliceModal: "Slice all plates" toggle for multi-plate sources — Re-slicing a multi-plate 3MF (e.g. a "parted statue" project where each plate carries a different body part) required opening the slice modal once per plate, picking the printer / process / filaments every time, and ending up with one archive per plate. The footer now has a "Slice all N plates" checkbox for multi-plate sources: tick it and the "Slice" button flips to "Slice all N plates", submitting
plate=0instead of the picked plate index. The backend forwards this as the BS CLI's--slice 0"all plates" sentinel, which produces a single output 3MF whoseMetadata/plate_N.gcodeentries cover every plate — one slice call, one archive, every plate inside. Filament dropdowns also adapt: with the toggle on, they show the union of every plate's slot usage (a slot a plate-2 part paints with but plate 1 doesn't was previously invisible — the user could only pick filaments for the actively-viewed plate). The union is computed client-side from the existingplatesQuery.data.plates[*].filamentspayload, so no extra round-trip. The backendSliceRequest.platefield's range relaxed fromge=1toge=0to admit the sentinel (the schema's docstring spells out the three semantics:None→ default plate 1,0→ all plates,>= 1→ that plate). The substitute-unused-filaments pass becomes a no-op forplate=0(no concept of "unused" when every plate counts), which is correct — in slice-all mode every slot the project defines IS used by something. The toggle is hidden on single-plate / STL sources where it'd be meaningless. Cross-class slice-all is handled by a per-plate loop: BS CLI's--arrangeis project-wide, so--slice 0 --arrange 1on a cross-class source consolidates every plate's objects onto a single target bed — either packing everything onto one plate or rejecting with "Some objects are located over the boundary of the heated bed" when nothing fits. When Bambuddy detectsplate=0combined with a class crossing, it falls back to slicing each plate independently (plate=N, arrange=true), then merges the N single-plate 3MF outputs into one multi-plate 3MF inmerge_plate_3mfs— overlays each plate'sMetadata/plate_N.{gcode,gcode.md5,json,png,_small.png,no_light_N.png,top_N.png,pick_N.png}onto the first plate's base 3MF and re-assemblesMetadata/slice_info.configto list every plate's slice block. The resulting archive's totals are the sum of each plate's print time + filament usage. Newcount_plates_in_3mfparsesmodel_settings.configfor<metadata key="plater_id" .../>entries to know how many plate calls to make. Cost: N × per-plate slice time; for a 5-plate Mewtwo on H2D that's ~70s wall clock vs the single-call same-class path. Progress toast shows loop position: each per-plate sub-slice forwards the originalprogress_request_id+ callback so the toast keeps showing the sidecar's stage messages, with the snapshot augmented withmulti_plate_index/multi_plate_count— the toast renders "Plate 2 of 5 • Mewtwo.gcode.3mf — Generating G-code (47%) — 23s" instead of just elapsed time. Newslice.runningWithProgressMultiPlatei18n key translated across all 9 locales. Per-plate cover images preserved: BS CLI with--arrangeregenerates plate gcodes but rarely writes a freshMetadata/plate_N.png, so the merged 3MF would have only plate 1's cover. The merger now takes the source 3MF as an optional fallback and lifts the source's per-plate render (plate_N.png/plate_N_small.png) into the merged file when the sliced output is missing it — same fallback approach as the archive-card thumbnail fix. Final test coverage: 26 unit tests intest_slicer_3mf_convert.py(extract canonical model, count plates, merge with overlay / passthrough / source-thumbnail fallback / sorted plates, substitute unused-slot filaments) + 3 intest_slicer_api.py(arrange flag wire format on preset and bundle paths) + 9 intest_library_slice_api.py(guard no-op semantics, re-sliced thumbnail / bed_type lifts, a new cross-class slice-all integration test that mocks the sidecar, asserts the backend loops per-plate witharrange=true, and verifies the merged archive containsplate_1..plate_N.gcode) + 2 intest_archive_service.py(Auxiliaries thumbnail fallback) + 4 inSliceModal.test.tsx(slice-all toggle sendsplate=0, toggle hidden for single-plate, plus 2 pre-existing tests for the picked-plate behaviour) + 2 new inSliceJobTrackerContext.test.tsx(toast prefixes "Plate X of Y" when the snapshot carries the loop fields; no prefix on plain single-plate slices). 659 backend / 42 frontend tests green; backend ruff + frontend build + i18n parity all clean. 2 new tests inSliceModal.test.tsx(toggle sendsplate=0to the backend; toggle hidden for single-plate sources) plus updates to the existing plate-picker test for the new label scheme. All 9 locales translated. Frontend build clean, i18n parity green at 4983 keys × 9 locales. - System Health — log scanner that surfaces self-fixable issues before they become support tickets — Complements the active Connection Diagnostic with a passive check: it scans Bambuddy's recent app log against a curated catalog of known failure signatures and reports what it finds. The catalog (
backend/app/services/log_health.py) is a deliberate allowlist — only known-bad, actionable patterns match, so a healthy install reports nothing and noisy benign churn (the occasional MQTT reconnect after a Wi-Fi blip) is gated behind a per-signaturemin_countthreshold. Six seed signatures cover the recurring "layer 8" causes from the closed-issue triage: rejected access code, FTPS :990 timeout, FTPS TLS handshake failure, flapping MQTT connection, unreachable camera (RTSPS :322), and SQLitedatabase is lockedcontention. Each finding is deduped (occurred N×, last seen …), classified as you can fix this / environment / please report this, and carries a deep-link to the troubleshooting wiki; sample log lines are sanitized (IPs, serials, access codes redacted) before they leave the process. Exposed viaGET /system/healthand surfaced on two surfaces that share oneSystemHealthPanelcomponent: a System Health section on the System page (on-demand re-scan), and inline in the bug reporter when the form opens — so a setup mistake gets self-resolved instead of becoming a GitHub issue. The Add-Printer and Edit-Printer dialogs also gained a setup-time pre-flight: saving now runs the connection diagnostic and, if a check fails, warns with a "save anyway" escape hatch instead of silently saving a printer that will immediately show offline. Log-reading and redaction primitives were extracted fromroutes/support.pyinto a sharedbackend/app/services/log_reader.py(behaviour-preserving). 13 backend tests (test_log_health.py,test_system_api.py) and 8 frontend tests (SystemHealthPanel,BugReportBubble,AddPrinterPreflight,EditPrinterPreflight); all strings translated across the 9 locales. Backend ruff clean, full unit suite green, frontend build clean, i18n parity green. - Event-loop stall watchdog — makes a frozen backend self-diagnose (#1486 groundwork) — Several "container hangs after adding a printer" reports share a signature that leaves nothing to act on: the HTTP server goes silent,
/healthhangs, the process may stop responding to SIGTERM — and the logs just stop mid-stream with no traceback, because a frozen asyncio event loop cannot log anything. Newbackend/app/services/loop_watchdog.pycloses that blind spot: an async heartbeat re-armsfaulthandler.dump_traceback_later()every 10s, always 30s ahead. While the loop ticks, the timer is cancelled and re-armed before it can fire; if the loop stalls, the heartbeat can't re-arm and faulthandler's dedicated C-level timer thread — which runs independently of the frozen loop — dumps every thread's stack to stderr. The blocked frame then appears indocker compose logs, turning an un-diagnosable freeze into a one-command capture. Started in the app lifespan after migrations, stopped cleanly on shutdown; 30s threshold is well above any legitimate on-loop operation, so a trip always means a real bug. 5 unit tests intest_loop_watchdog.py(arms the timer, idempotent start, stop disarms + cancels, heartbeat interval below the threshold, survives a re-arm error). Backend ruff clean; full app lifespan verified via the integration suite. - Slicer: process & filament profiles filtered by the selected printer (#1325, requested by @IndividualGhost1905) — In the server-side Slice dialog, picking a printer profile now filters the Process and Filament dropdowns to presets compatible with that printer; presets that resolve to a different Bambu model drop into a trailing "Other printers" group instead of cluttering the main list. Matching uses the slicer's own
compatible_printerslist for imported (local) presets, and falls back to the@BBL <model>name suffix for cloud and standard presets, so all three tiers are covered. Compatibility-unknown presets (custom or untagged) are never hidden. Defaults follow suit — the pre-picked process and per-slot filament now prefer a printer-compatible preset, and switching the printer re-picks any selection left incompatible. The printer and process dropdowns also default to the preset names embedded in the source 3MF'sproject_settings.configwhen those presets are available, instead of always taking the first listed preset. Newfrontend/src/utils/slicerPrinterMatch.ts(11 unit tests) andextract_embedded_presets_from_3mf(5 unit tests);UnifiedPresetnow carriescompatible_printers, exposed for the local tier (backend/app/api/routes/slicer_presets.py); the plates endpoints returnembedded_printer/embedded_process. Parity green, build clean. - Spanish (es) translation (#1243, requested by @MiguelAngelLV) — Bambuddy now ships a full European Spanish locale. New
frontend/src/i18n/locales/es.tstranslates all 4899 keys with placeholders, plural forms, and inline markup preserved; registered infrontend/src/i18n/index.tsand selectable as "Español" in the language picker. The parity checker auto-discovers the file —frontend/scripts/check-i18n-parity.mjsgained anES_COGNATESallow-list for genuine Spanish cognates and brand/format tokens. Brings the supported-language count to 9 (en / de / es / fr / it / ja / pt-BR / zh-CN / zh-TW). Parity green, frontend build clean. - Currency: Belize Dollars (BZD) added to the Settings → Cost currency dropdown (#1454, requested by @PLGuerraDesigns) — Reporter accurately tracks 3D-printing filament costs in his local currency and BZD wasn't selectable, forcing a manual 2:1 mental conversion from USD. Added
BZD: 'BZ$'tofrontend/src/utils/currency.tsnext to MXN (Americas dollar-prefix grouping);getCurrencySymbol('BZD')returns'BZ$'and the SUPPORTED_CURRENCIES list now has 30 entries. Unit test added infrontend/src/__tests__/utils/currency.test.tscovering the symbol lookup and presence in SUPPORTED_CURRENCIES; entry-count assertion bumped to 30 so any future additions/removals are caught immediately. 14 currency tests green; frontend build clean. - Connection Diagnostic — self-service triage for "printer won't connect / won't print" — A triage review of recently-closed issues found roughly a third were user-side setup errors (printer not in LAN developer mode, blocked ports, Docker bridge networking, wrong access code, printer on a different subnet), each costing a multi-round-trip "enable debug logging → build a support bundle → upload it" exchange. A new diagnostic (
backend/app/services/printer_diagnostic.py) runs those checks automatically: TCP reachability of MQTT 8883 / FTPS 990 / RTSPS 322, LAN developer mode, Docker network mode, printer/host subnet match, and MQTT credential class — each returning a pass / fail / warn / skip status with a localized plain-language fix. Exposed viaGET /printers/{id}/diagnostic(saved printer) andPOST /printers/diagnostic(pre-save Add-Printer flow), and surfaced as a one-click "Run diagnostic" from the printer card actions menu (plus a quick button on the card when a printer is offline), the Add-Printer dialog, and a new Connection Diagnostic section on the System page. The in-app bug reporter scans configured printers when the report form opens and always shows the result — a healthy confirmation when nothing's wrong, or the detected problem and its fix inline — so setup mistakes get self-resolved instead of becoming GitHub issues. The GitHubconfig.ymltroubleshooting link was repointed from the wiki source repo to the rendered troubleshooting page. Backend service unit tests (15) and frontend modal tests (3) added; all diagnostic strings translated across the 8 locales. Backend ruff clean, frontend build clean, i18n parity green.
Changed
- Settings → SpoolBuddy: CPU load tile added to the device card — The SpoolBuddy daemon's heartbeat already reports
load_avg(1/5/15 min) andcpu_countviasystem_stats(seespoolbuddy/daemon/system_stats.py), but the device card on the Bambuddy SpoolBuddy settings only rendered CPU temp / memory / disk / system uptime. Adds a fifth tile next to CPU temp showing the 1-minute load average alongside core count and a percent-of-cores readout — for a 4-core Pi:1.20 / 4 (30%). Falls back to a bare load number whencpu_countisn't reported, and the tile is hidden entirely when the daemon doesn't emitload_avg(older builds). Useful for spotting the "I2C/SPI stuck after idle overnight" pattern early — sustained high load before the bus dies points at runaway daemon work rather than a kernel hang. Translated across all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW). Frontend build clean, i18n parity green. - Virtual printer: setup diagnostic + one-click slicer-certificate export — Two recurring virtual-printer support pains, addressed on the Virtual Printers settings page. (1) Setup check — a new stethoscope action on each VP card runs
GET /virtual-printers/{id}/diagnosticand shows a pass/fail/warn/skip checklist: VP enabled, services running, bind interface still exists, access code set, target printer (proxy mode), and — decisively — a live TCP probe of the FTP/MQTT/discovery ports on the bind IP. The manager swallows per-service start errors (run_with_logging), so a service object can exist while nothing is actually listening; probing the bind IP from outside is the only reliable signal, and it catches the common "VP doesn't show up in the slicer" bind-IP-conflict and stale-interface cases. Newbackend/app/services/virtual_printer/diagnostic.py+VPDiagnosticResultschema +VirtualPrinterDiagnosticModal.tsx. (2) Slicer certificate — virtual printers present a TLS cert signed by a shared CA the slicer must trust; until now users had todocker execin andcat bbl_ca.crtto get it. A new "Slicer certificate" row on the Virtual Printers settings card (alongside the Archive name source toggle) offers Copy and Download (bambuddy-virtual-printer-ca.crt) plus the CA's SHA-256 fingerprint, served byGET /virtual-printers/ca-certificate— only the public certificate, never the CA private key. The CA is generated on demand so the button works before the first VP is enabled. Copy uses a non-secure-context fallback (Bambuddy is usually on plain-HTTP LAN), extracted into a sharedutils/clipboard.ts. 9 backend diagnostic/CA unit tests + 4 route integration tests + 6 frontend tests (diagnostic modal, clipboard helpers); allvpDiagnostic.*/virtualPrinter.caCert.*strings translated across the 9 locales. Backend ruff clean, frontend build clean, i18n parity green. - Bug-report panel: connection diagnostic no longer overflows on multi-printer setups — The "Report a Bug" panel scans every configured printer on open and surfaces connection problems inline so users can self-fix before filing. The first cut rendered a full ~6-row checklist for each problem printer stacked vertically; a user with many printers all reporting issues pushed the description box, screenshot uploader and Submit button far below the fold in the
max-w-md/max-h-[80vh]panel. The diagnostic section is now a compact summary — one line ("N of M printers have connection issues") followed by the affected printers as collapsed rows (healthy printers count toward M but render no detail). Each row expands on demand to that printer's full checklist via the sharedCollapsiblewidget; when exactly one printer has problems the row is auto-expanded since that's the case where inline detail is wanted with no extra click. The panel now stays a fixed ~3 lines plus one row per affected printer regardless of fleet size, keeping the report form reachable. Healthy-fleet confirmation line is unchanged. NewbugReport.diagnosticSummarykey (with{{problems}}/{{total}}) replaces the staticdiagnosticHeading;diagnosticIntroreworded to be printer-count-neutral and point at the expand affordance — both translated across all 9 locales. 2 new tests inBugReportBubble.test.tsx(multiple problems stay collapsed and expand on click; a single problem auto-expands); 11 tests green; frontend build clean; i18n parity holds at 4903 keys × 9 locales. - Color Catalog sync now identifies itself as Bambuddy to filamentcolors.xyz — The FilamentColors.xyz sync client in
inventory.pycreated itshttpx.AsyncClientwith noUser-Agent, so it leaked httpx's defaultpython-httpx/x.ystring — the only outbound client that did (bambu_cloud,makerworld,firmware_checkall send the honestBambuddy/1.0 (+https://github.com/maziggy/bambuddy)). It now sends the same honest UA, consistent with the rest of the codebase. Surfaced while investigating #1456 (a Cloudflare403on the sync that turned out to be the reporter's network/IP reputation, not Bambuddy — the UA leak was a separate inconsistency found in passing, and this change does not by itself resolve a Cloudflare IP block). - Filament inventory: grouped rows now show group totals (#1368, requested by a user) — With "Group similar" enabled, the collapsed group row showed the values of a single member (the first spool) — so a group of five 1 kg spools displayed "1000 g" instead of the 5 kg it actually held. The group header now aggregates across all members: the table view's Label, Net, Gross, Used and Remaining columns and the grid card's weight figure show group totals, while identity columns (Material, Brand, Colour) and the Cost/kg rate stay per-spool-correct. Per-spool-only fields with no meaningful total (dates, location, note, tag ID) keep showing the representative member's value; the expanded individual rows are unchanged. New
aggregateGroupSpoolhelper infrontend/src/utils/inventoryGrouping.tswith 4 unit tests. Frontend-only — all data was already in the spool list. — Previous behaviour disabled the Slice button whenever the source 3MF's bound printer model didn't match the user's picked printer profile, on the theory that the slicer CLI "cannot re-slice a 3MF for a different printer" and would silently fall back to embedded settings to produce a wrong-printer file. Step 0 empirical test on 2026-05-20 disproved that: an 18-color H2D-boundTrent900.3mfsliced via the X1C bundle (POST /slicewithbundle=cb…X1C, printerName=# Bambu Lab X1 Carbon 0.4 nozzle) produced 2.3 MB of genuinely X1C-compatible G-code in 1.8 s —printer_modeloverridden toBambu Lab X1 Carbon,printable_areato 256×256 (X1C bed, not H2D's 350×320),printable_height250 (vs 325),bed_exclude_areapopulated with X1C's 18×28 corner zone,nozzle_diametersingle 0.4 (vs H2D's dual0.4,0.4), and the full X1Cmachine_start_gcodesequence baked in. The sidecar takes printer / process / first-N filament names from the picked bundle and only inherits embedded values for unused trailing slots — bed size, kinematics, start sequence all come from the target. Behavioural change: dropped!printerMismatchfrom the SliceModalisReadypredicate so the Slice button stays enabled when models differ. The amber banner was first softened to an info message, then removed entirely — re-slicing across printers is now just a normal slice, the picker UI already shows which printer was picked, no second confirmation needed. Dead-code removal (same drop): with no banner, thesource_printer_modelfield on the/library/files/{id}/platesand/archives/{id}/platesresponses had zero consumers; theextract_source_printer_model_from_3mfhelper inthreemf_tools.py(which opened the 3MF zip and readMetadata/project_settings.configon every plate request) had zero callers. Removed both response keys, both backend extractions, boththreemf_toolsimports, the helper itself, its 6 unit tests, thesource_printer_modelfield fromfrontend/src/types/plates.ts(PlateMetadata + LibraryFilePlatesResponse), and 2 obsolete SliceModal tests that exercised the now-impossible matched-printer / legacy-archive paths. i18n discipline cleanup (same drop, per feedback_no_followups + feedback_translate_dont_fallback): every t() callsite in SliceModal.tsx had an inline EnglishdefaultValue:or positional-second-arg English fallback — 22 sites in total. With 8 locales shipped, those fallbacks are dead weight at best, and an actual i18n-violation when the key is missing because non-English users would silently see English. Audit found 3 keys (slice.bundle,slice.bundleNone,slice.bundleAllRequired) that had no corresponding entry in any locale file — they were being served from the inline English fallback exclusively, meaning every non-English user was already seeing those three labels in English. Added all 3 to all 8 locales with real translations, then stripped the English fallback from every t() call in SliceModal.tsx. Theslice.printerMismatchkey was removed from all 8 locales (banner is gone). Why this matters: a recurring pain point for users importing MakerWorld project files where the original creator's printer often differs from the user's; previously they had to round-trip through BambuStudio's "convert project" flow to re-export. Now Bambuddy re-slices in-place with no UI friction. Tests: the existing SliceModal "shows mismatch warning AND disables Slice" test was rewritten to assert "does not surface any cross-printer banner AND keeps Slice enabled when models differ" (regression guard against the gate being re-added); 2 obsolete tests deleted. 32 SliceModal tests green (was 34, -2 dead tests); 49 threemf_tools tests green (was 55, -6 helper tests); 24 plates-route tests green; frontend build clean; backend ruff clean; i18n parity check passes 4858 keys × 8 locales (net +2 vs pre-fix: +3 bundle keys, -1 printerMismatch).
Security
- idna: bump to
>=3.15to clear CVE-2026-45409 (ReDoS inidna.encode()with crafted Unicode payloads, e.g."٠" * Nor"・" * N + "漢") — Transitive dep pulled in by anyio / httpx / requests / yarl; not directly pinned, which is why it lingered at 3.13. Added an explicitidna>=3.15floor inrequirements.txtbetween Authentication and HTTP-client blocks with a comment explaining why it's pinned (so a future downstream loosening doesn't silently downgrade us). Verified viapip-auditclean post-upgrade. - starlette: bump floor to
>=1.0.1to clear PYSEC-2026-161 —starletteis a transitive dep pulled in by fastapi, whose range still admits the vulnerable 1.0.0 build, so a freshpip installwould silently pick it back up. Added an explicitstarlette>=1.0.1floor inrequirements.txtunder the urllib3 pin with a why-comment matching the same pattern as the idna/urllib3 entries. Release-notes reviewed for both 1.0.1 (single fix: ignore malformedHostheader when constructingrequest.url) and 1.1.0 (the resolver actually picked up 1.1.0): three behavioural changes —FileResponsefalls back toapplication/octet-streamwhenmimetypes.guess_type()can't resolve (Bambuddy has 2FileResponsecalls without explicitmedia_type, both servingindex.htmlwhere guess_type still resolves totext/html, plus custom-icon serving inexternal_links.py:261where the new fallback is a security improvement),HTTPEndpointonly dispatches standard HTTP verbs (grepfound zeroHTTPEndpointusages in Bambuddy — pure FastAPI router code),StaticFiles.lookup_pathrejects absolute paths in requests (the 4 mounts inmain.py:5503-5525pass absolute base directories to the constructor, which is unaffected — only path-traversal-style request paths get rejected). Full backend test suite green (5300/5301; the 1 failure is a pre-existing-n 30parallelism flake unrelated to starlette and passes in isolation). Verified clean viapip-auditpost-upgrade. - PyJWT CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): permanently ignored in pip-audit — Advisory is disputed by the PyJWT maintainers, with the advisory description literally noting "this is disputed by the Supplier because the key length is chosen by the application that uses the library."
fix_versions=[]on the advisory confirms no PyJWT patch exists or will exist. Bambuddy is not affected:backend/app/core/auth.py:184auto-generates secrets viasecrets.token_urlsafe(64)(~86 chars of entropy, far above any sane minimum) and the file-loaded path at:177rejects secrets shorter than 32 chars. Added a permanent--ignore-vuln CVE-2025-45768to.github/workflows/security.ymlwith an inline comment citing the file:line evidence so a future maintainer reviewing the ignore list sees why it's load-bearing. Also dropped the stale--ignore-vuln CVE-2026-4539for Pygments — Pygments has since shipped a patched version and the ignore is no longer load-bearing (verified:pip-audit --ignore-vuln CVE-2025-45768alone reports clean).
Fixed
-
Support bundle + bug-report submission now include the live diagnostic snapshot — Three diagnostics (Connection Diagnostic per printer, Virtual Printer Setup Diagnostic per enabled VP, Log Health Scanner) have shipped on the System page and inline in the bug-report bubble since
6bc6a1d6/e222a0ef/ed31b8f4, but the results were only ever shown to the user — never persisted into the downloadable support ZIP or the submitted GitHub issue. A report saying "looks broken in Bambuddy" arrived with no actionable signal beyond raw logs. Fix: newservices/diagnostic_snapshot.collect_diagnostic_snapshotruns all three concurrently with an outer per-probe 15 s wall-clock cap (so a hung interface adds at most ~15 s to bundle generation regardless of fleet size —asyncio.gather, total ≈ max(per-cap) not sum). Fail-soft per probe: a crash inside one printer's check emits{"printer_id": N, "error": "..."}for that entry rather than nuking the whole snapshot — partial result beats a 500. Wired into_collect_support_info()so both flows (POST /support/bundleandPOST /bug-report/submitviasupport_info=...) pick up the newdiagnosticstop-level key without their own changes. Private-data sanitization — the diagnostic schemas embed raw IPv4 in three places (PrinterDiagnosticResult.ip_address, network-mode check'sparams.{printer_ip, host_ip}, VP diagnostic'sparams.bind_ip), and the snapshot adds printer names. None of those should leak. The snapshot now runs a recursive sanitizer on the full result tree before returning: known DB-listed values (printer name, IP, serial, access code) get the same[PRINTER]/[IP]/[SERIAL]/[ACCESS_CODE]labels the log sanitizer already applies (via the sharedcollect_sensitive_strings), and an IPv4-regex fallback catches IPs the DB doesn't know about — most importantly the Bambuddy host IP returned by_get_host_ip()and any VPbind_ipthe user picked at setup. Live-DB smoke test confirms zero raw IPv4 instances in the serialized snapshot output. Progress indicators: the bubble's "submitting" view and the System page's Download button now render a static four-line checklist showing what's running (printer connectivity → VP setup → log scan → submit/build ZIP) — communicates the longer wait honestly without faking server-side phase progress we can't actually track. Tests: 6 new intest_diagnostic_snapshot.py— empty-input shape stable, per-printer / per-VP result coverage, fail-soft on a single-probe crash,timed_outmarker when a probe exceeds the per-probe cap (test patches the cap to 0.05 s), end-to-end IP sanitization across all five field shapes (top-levelip_address,printer_ip,host_ip,bind_ip, plus IPs embedded in log-health sample lines) with a final regex sweep over the JSON-serialized result asserting zero raw IPv4 escapes, concurrent execution proof (4 × 0.2 s probes complete in < 0.5 s, would be 0.8 s sequential). Existing 27 BugReportBubble + SystemInfoPage frontend tests still pass; 9-locale i18n parity check clean (4993 leaves per locale, 9 new keys added with real translations everywhere — no English fallback). Backend ruff clean. -
"Prefer Lowest Remaining Filament" now uses Bambuddy's inventory weight, not just the printer's RFID counter (#1508, reported by @kleinwareio) — Reporter has an inventory spool cloned to slot 1 and the original (much further used) in slot 4 of the same P1S AMS, with the preference enabled, and the dispatch consistently picked slot 1 (the fresh clone) instead of slot 4 (the original they wanted to finish first). Root cause is the
prefer_lowestsort in_match_filaments_to_slots(print_scheduler.py): the sort key readsf.get("remain", -1)straight out of_build_loaded_filaments, which sources it from MQTT AMStray.remain— the printer firmware's own RFID-decremented value. Two problems with that signal: (a) it's only populated for Bambu RFID spools, so every non-RFID / 3rd-party / user-loaded tray reports-1and gets clamped to a sentinel — multiple non-RFID spools then tie in the sort and Python's stable sort collapses to AMS-slot insertion order, so slot 1 always wins; (b) even when set, it's the printer's counter, not Bambuddy'slabel_weight - weight_used(internal mode) or Spoolman'sremaining_weight(Spoolman mode) — the two diverge any time the user re-spools, swaps cardboard, or runs a print outside Bambuddy. The reporter is on internal-inventory mode with non-RFID spools — both failure modes apply, hence slot 1 every time. Fix: when a slot is bound to a Bambuddy / Spoolman spool, that inventory record's remaining weight becomes the sort signal. New async helper_build_inventory_remain_overrides(db, printer_id, loaded)returns{global_tray_id: remaining_grams}for slots with an assignment — internal mode joinsSpoolAssignment→Spoolonce per dispatch, Spoolman mode joinsSpoolmanSlotAssignmentthen fetches each spool through the existing_spoolman_remaining_grams(shared withfilament_deficit.py, parity rule per feedback_inventory_modes_parity). The new_prefer_lowest_sort_keyconsumes that map alongside the legacy MQTT field with a two-tier comparison: inventory-tracked spools always sort BEFORE MQTT-only spools, then ascending by remaining within each tier, then ascending byams_id * 4 + tray_idas the deterministic slot tie-breaker. The tier flag dominates so we never compare grams (inventory) against percent (MQTT) — no unit-conversion contortions. MQTT-only behaviour is preserved exactly:remain = -1still maps to the 101 sentinel and slot order still decides on ties, so users who haven't bound any spools see no change. External / VT tray slots are skipped (tracked separately from AMS bindings). Lookup runs only whenprefer_lowest_filamentis enabled — no extra DB hit for users who don't use the preference. Tests: 6 new inTestPreferLowestInventoryOverrideintest_scheduler_ams_mapping.py(inventory override beats MQTT remain — the literal reporter scenario with 950 g clone vs 50 g original; zero-grams still sorts first within its tier; inventory tier beats MQTT tier regardless of value; tied inventory grams break to lower slot; no-override falls through to MQTT — regression guard for un-tracked spools; legacyremain = -1still sentinel-sorts last when override map is None) + 7 new intest_scheduler_inventory_remain.pycovering_build_inventory_remain_overridesdirectly (internal mode returns label_weight − weight_used per bound slot; external slots skipped; empty loaded short-circuits; over-consumed spool clamps to 0 g; unbound slots absent from map; Spoolman mode uses_spoolman_remaining_gramsfor parity; Spoolman unreachability silently omits that slot). 102 scheduler + inventory tests green; backend ruff clean. -
X1/H2/P2 live camera no longer fails with
Address already in useon transitional ffmpeg builds (#1504, reported by @rage03usa, confirmed by @PawseHaxor) — On a native Ubuntu install with the Jammy-era system ffmpeg, the RTSP live-view path retried indefinitely withUnable to open RTSP for listening … Address already in use. Snapshots, the camera diagnostic, and OrcaSlicer all kept working — only live view was broken. Cause: the ffmpeg argv built inbackend/app/api/routes/camera.py(added in530a7a46as part of an RTSP-stability bundle) passed-timeout 30000000. That ffmpeg version deprecated the original-timeout(socket I/O microseconds) and repurposed the name to mean the RTSP listen-mode incoming-connection timeout — any non-zero value implies-listen. ffmpeg then flipped into RTSP server mode and tried to bind the same localhost port Bambuddy's TLS proxy was already listening on, hence EADDRINUSE on every retry (the odd-port pattern @PawseHaxor noticed is coincidence — the ephemeral allocator just picked odd values that run). The reporter's own workaround (drop the option) works but silently loses the socket-level read timeout, so a hung TLS handshake would block past the OS TCP timeout instead of failing fast into the existing reconnect loop. Why this can't be a one-line literal swap: ffmpeg has shipped three arrangements of this option over time and Bambuddy supports the full range. Pre-deprecation builds:-timeoutis the socket I/O timeout. Transitional builds (~late-4.x, what the reporter is on):-timeoutis the broken listen-mode option,-stimeoutis the replacement. Modern ffmpeg (5.x / 6.x / 7.x — current Debian 13, Ubuntu 24.04, current Homebrew):-stimeoutwas removed entirely and-timeoutis back to socket I/O. So both literals regress one half of the install base. Fix: a newrtsp_socket_timeout_flag()helper inbackend/app/services/camera.pyprobesffmpeg -h demuxer=rtsponce at first use and picks-stimeoutwhen ffmpeg advertises it (transitional window) or-timeoutotherwise (modern + very old). The result is cached for the process lifetime — ffmpeg won't swap mid-run. The function returns the option name without a leading dash so callers prepend it themselves (no empty-flag formatting bug). Wired into both RTSP ffmpeg call sites —routes/camera.py(printer camera) andservices/external_camera.py(external RTSP) — in lockstep, same TLS-proxy + ffmpeg pattern, same regression. The reporter had tried-listen_timeout(doesn't help — we don't want listen mode) and-rw_timeout(AVIO-level, RTSP demuxer doesn't honour it on its control socket), but no manual swap could be correct for both transitional and modern installs simultaneously. Tests: 8 intest_ffmpeg_rtsp_timeout_flag.py— 6 unit tests for the probe (picks-stimeoutwhen advertised, falls back to-timeouton modern, defaults to-timeoutwhen ffmpeg missing or probe raises, caches across calls, substring-match guard against false-positives on-listen_timeout), 2 parametrised regression guards against either RTSP ffmpeg argv re-hard-coding a literal flag instead of consuming the probe. 37 (probe + existing external-camera) tests green; backend ruff clean. -
SliceModal: process / filament dropdowns now filter by nozzle diameter too, not just printer model (#1325 follow-up #2, reported by @IndividualGhost1905) — With the @BBL name fallback in place, the reporter saw that an X2D 0.4 selection still mixed 0.2 / 0.6 / 0.8 nozzle process variants into the main list. The fallback's regex stripped any trailing
<size> nozzlesuffix from both sides before comparing, so"Bambu Lab X2D 0.4 nozzle"and"0.40mm Strength @BBL X2D 0.8 nozzle"both reduced to"X2D"and matched. The bundle path was already nozzle-correct (a.bbscfgis scoped to one printer-preset-name including its nozzle, so the bundle-side exact-match was nozzle-aware); only the name fallback needed fixing. Fix:extractPrinterPresetModelandextractBblTokennow each return{ model, nozzle }. The nozzle is the parsed string ("0.4" / "0.6" / etc.) ornullwhen the name has no suffix.classifyByBambuNametreats anullprocess nozzle as"0.4"— Bambu's convention is to omit the suffix on 0.4 (the default) and include it for 0.2 / 0.6 / 0.8, exactly as the reporter described. Bothmodelandnozzlemust compare equal for a'match'; differing nozzles fall into the existing "Other printers" group, no new group label needed. If the selected printer preset name has no parseable nozzle (non-Bambu / hand-typed), the matcher degrades to model-only — Bambu printer presets always include nozzle in practice, so this is defensive. Tests: 9 new inslicerPrinterMatch.test.tscovering the matrix (0.4 printer vs no-suffix / 0.6 / 0.8 process; 0.6 printer vs 0.6 / no-suffix-=-0.4; explicit 0.4-suffix-on-process still matches 0.4 printer; same rule on filament presets; wrong-model dominates over matching-nozzle; no-nozzle printer name degrades to model-only); one existing test reframed (the case that previously asserted a 0.6-nozzle process matched a 0.4 printer — the exact bug — now asserts mismatch). 46 slicerPrinterMatch + 34 SliceModal tests green; frontend build clean. -
Timelapse now attaches to the archive after a backend restart mid-print (#1485 follow-up, reported by @pwostran) — With the duplicate-archive fix from #1485 in place, a restart mid-print stopped creating ghosts — but the resulting archive came back without its timelapse video (only the finish snapshot was attached). Cause is a side-effect of the #1304 first-push guard: on the first MQTT push after Bambuddy starts (
_previous_gcode_state = None),is_new_printis deliberately False soon_print_startdoesn't fire — which prevents duplicate archive creation but also prevents the timelapse-baseline capture, since both live behind the same callback. At PRINT COMPLETE,_scan_for_timelapse_with_retriesfinds an empty_timelapse_baselinesfor the printer and falls into the "take baseline now" fallback inmain.py. By that point the printer has already uploaded the in-flight MP4, so the snapshot includes it. Every retry then reports "N files found / no new files since baseline" and the scan gives up. The reporter's support bundle is the smoking gun — pre-reboot baseline of 7 files, post-reboot fallback baseline of 8 files (including the just-uploaded one), 4 retries all unable to see the diff. Fix:bambu_mqtt.pynow fires a siblingon_print_running_observedcallback inside the "Now tracking RUNNING state" branch when the first-push guard suppresseson_print_start.main.pywires it to a thin handler that fetches the printer row from DB and calls the existing_capture_timelapse_baseline_at_start. The callback only fires the first time we observe RUNNING per session (gated on the samenot self._was_runningbranch the timelapse-flag restore already lives in), so a normal print start path is unaffected. The handler is also idempotent: if a baseline already exists for that printer, it returns without touching it. Safe because the printer doesn't upload the timelapse until after PRINT COMPLETE, so a baseline captured any time during the in-flight print is still pre-upload — no narrow window. The plumbing (set_print_running_observed_callbacksetter, in-connect_printerwrapper, constructor pass-through) mirrors the existingon_print_start/on_print_completecallback chain inprinter_manager.py. Tests: 7 new inTestPrintRunningObservedCallbackintest_bambu_mqtt.py(fires on first RUNNING after startup, doesn't double up withon_print_start, fires only once per session, skips on non-RUNNING / missing file / no-callback-set, payload shape mirrorson_print_start); 3 new in a dedicatedtest_timelapse_baseline_restart_recovery.py(handler captures the printer's existing-videos snapshot into_timelapse_baselines, skips when a baseline already exists, skips when the printer row was deleted between push and handler). 336 MQTT + print-start + timelapse tests green; backend ruff clean. -
SliceModal: process / filament dropdowns now filter for users who haven't uploaded slicer bundles (#1325 follow-up, reported by @IndividualGhost1905) — The original #1325 fix replaced a stale hardcoded
@BBL <model>allow-list with bundle-based compatibility: a process / filament preset was classified against the selected printer by consulting the user's uploaded Slicer Bundles (.bbscfg). That works perfectly for users who have uploaded bundles for every printer their cloud catalogue covers — and silently no-ops for everyone else: every cloud preset resolves to'unknown', nothing moves into "Other printers", and the dropdown looks identical to the pre-fix state. Fix: restored the@BBL <token>name fallback as a third tier below the bundle path, but with the token-to-printer mapping driven by the backend's canonicalPRINTER_MODEL_MAP(backend/app/utils/printer_models.py) instead of a duplicated frontend table. A newGET /api/v1/slicer/printer-modelsroute ships the mapping unmodified;slicerPrinterMatch.buildCompatibilityIndexaccepts it as a second arg, inverts it into a short-code → display-fragment table (X1C→X1 Carbon,P2S→P2S,A1 Mini→A1 mini, …), andpresetCompatibilityuses it only aftercompatible_printersand the bundle index have already returned'unknown'. The match is case- and whitespace-insensitive ("A1 mini","A1 Mini"and"a1mini"all compare equal). When the registry doesn't list a token, the matcher falls back to comparing the raw token against the printer-preset model fragment — so a brand-new "Q1" printer with@BBL Q1-tagged presets matches without any code change. Adding a new model only requires updating the existing backendPRINTER_MODEL_MAP(already the single source of truth foris_dual_nozzle_model, the rod-type/ethernet registries, and 3MF metadata normalisation) — no frontend table to keep in sync. Tests: 2 new intest_slicer_presets.py(/printer-modelsreturns the fullPRINTER_MODEL_MAP; the route hands back a copy, not the live module dict); the existing 25slicerPrinterMatch.test.tscases were extended to 36 covering: registry-driven X1C vs X1 Carbon match, A1 vs A1 mini disambiguation, H2D vs H2D Pro disambiguation, the previously-missing P2S / H2C / H2S / X2D, raw-token fallback for unregistered models, graceful degradation when the registry fetch hasn't resolved yet, thecompatible_printers-wins-over-name rule, and the bundle-wins-over-name rule. 38 slicer-presets + 36 slicerPrinterMatch tests green; backend ruff clean; frontend build clean. -
Cloudflare-fronted Bambuddy no longer needs an
unsafe-inlineoverride to load (#1460 follow-up, reported by @Soopahfly) — A Bambuddy instance behind Cloudflare logged an inline-script CSP violation on every page load: Cloudflare's bot-detection script (/cdn-cgi/challenge-platform/scripts/jsd/main.js) is injected into the HTML on the edge with a hash that changes per request, so it can never be allowlisted byscript-srchash. The contributor's workaround was to relaxscript-srcto'unsafe-inline'in their Nginx Proxy Manager — which works but defeats most of the CSP. Fix: the SPA CSP now stamps a fresh per-request nonce intoscript-src('self' 'nonce-<base64>'). Per Cloudflare's documented behaviour, when a nonce is present in the CSP header Cloudflare clones the same nonce onto its injected<script>and the inline script passes without'unsafe-inline'. Bambuddy's ownindex.htmlhas had no inline scripts since the SW registration moved to/sw-register.js(#1460 first PR), so no HTML body rewriting is needed —'self'continues to cover every script the app ships. Implemented via a 16-bytesecrets.token_urlsafe()nonce computed per request insecurity_headers_middleware. Separately,/manifest.json,/sw.jsand/sw-register.jsare now registered with@app.api_route(methods=["GET", "HEAD"])instead of@app.get— a plaincurl -I https://host/manifest.json(and several uptime scanners) HEAD-probe these routes and were getting405 Method Not Allowed, which surfaced in the issue as an apparent manifest-server bug. Tests: 3 new intest_security_headers.py—'nonce-…'is stamped into the SPAscript-srcdirective while'self'remains and'unsafe-inline'does not; the nonce is fresh per request across 5 sequential calls (collision probability ~0); HEAD on/manifest.json,/sw.js,/sw-register.jsnever returns 405. 22 security-header tests green; backend ruff clean. -
Insufficient-filament pre-print warning now fires on every dispatch path (#1496, reported by @needo37) — The "Pre-print checks now also warn when the spool has insufficient material" guard from #720 only fired on the
PrintModalsubmit path. Two other queue-dispatch paths bypassed it entirely: the green ▶ Play button on a staged (manual_start) queue row calledPOST /queue/{id}/start, which only flipped the manual_start flag with no filament check; and the Virtual Printer queue-mode intake (virtual_printer/manager._add_to_print_queue) parsed per-slot requirements for type matching only — never weight. Withauto_dispatch=Truethe scheduler would then dispatch unsupervised onto a doomed-to-fail spool. Fix: extracted the per-slot deficit calculation into a single backend helper (backend/app/services/filament_deficit.py) that both the route and the dispatch scheduler call against live spool state. Works for internal-inventory mode (SpoolAssignment→Spool.label_weight - weight_used) and Spoolman mode (SpoolmanSlotAssignment→SpoolmanClient.get_spool); Spoolman unreachability returns no deficit rather than wedging the queue. Thedisable_filament_warningssetting is honoured at the service boundary.POST /queue/{id}/startnow returns409 {detail: {code: 'insufficient_filament', deficit: [...]}}when short; the?skip_filament_check=truequery param is the "Print Anyway" bypass. The dispatch scheduler runs the same check just before each_start_printcall: a deficit promotes the item tomanual_start=True+filament_short=True(so the user must consciously click ▶) and a previously-flagged item whose spool was swapped to one with enough material clears the flag automatically on the next tick. A newfilament_shortboolean column onprint_queuecarries the flag; the queue row now renders a yellow "Insufficient filament for the assigned spool" badge when set, and the ▶ button catches the 409, opens anInsufficient Filament / Print Anywayconfirm modal showing each shorted slot's required-vs-remaining grams, and on confirm re-issues the start with the skip flag. Migration is idempotent and branches onis_sqlite()for theBOOLEAN DEFAULTsyntax. Tests: 8 intest_filament_deficit.py(deficit + sufficient + missing mapping + no printer + disabled-warnings + no-assignment + missing 3MF + multi-slot only-shorted-returned), 4 intest_scheduler_filament_deficit.py(block-on-deficit, clear-stale-flag, no-deficit no-op, helper-exception doesn't wedge), 2 new intest_print_queue_api.py(/startreturns 409 + structured payload,?skip_filament_check=truebypasses), and 2 frontend tests inQueuePage.test.tsx(badge renders on flagged row, ▶ click → 409 → modal → retry withskip_filament_check=true). 3392 unit + 63 print-queue integration green; backend ruff clean; frontend build + i18n parity (9 locales × 4979 keys) clean. -
File Manager "All Files" view showed nothing when every file lived in a subfolder (#1499) — The sidebar entry was meant to list every file across the library but instead returned only files at the library root, so a library with two folders and three files (all nested) appeared empty. Cause was an inverted boolean on the React Query call:
getLibraryFiles(selectedFolderId, selectedFolderId === null)passedinclude_root=truefor the "All Files" selection, which on the backend (library.pylist_files) means root files only — the opposite of what the UI wanted. Fix: passinclude_root=falsefor "All Files" so the backend returns every active file across folders (it remains a no-op when a specific folder is selected —folder_idtakes precedence). A new vitest regression case renders the page with one root file and one nested file and asserts both appear, and that the request goes out withinclude_root=false. 48/48 FileManagerPage tests green; frontend build clean. -
Archive filament colour now reflects the assigned inventory spool, not the slicer's 3MF (#1494, reported by @IndividualGhost1905) — A user added a
#000000black filament to the built-in inventory, assigned it to the printer, and printed from the desktop slicer; the print, AMS and inventory all showed it as black and the correct spool's weight decremented — but the resulting archive (and the Color Distribution graph) showed#161616. Root cause is two independent colour sources: an archive'sfilament_coloris parsed verbatim from the print job's 3MF (archive.py_extract_filament_inforeadsfilament_colourfromproject_settings.config), which carries the slicer's filament-slot colour — a value the user picks separately from the exact hex they curate on the Bambuddy inventory spool. The two are "close but not equal" (slicer near-black#161616vs inventory#000000), which is exactly the "always a similar colour, never an unrelated one" pattern the report describes. Fix: once usage tracking has resolved the print's filament slots to inventory spools, the spool colours are authoritative —_track_from_3mf(built-in inventory) andreport_usage(Spoolman mode) now overwrite the archive'sfilament_colorwith the slot-ordered, de-duplicated colours of the matched spools. The rewrite is all-or-nothing: it only applies when every used slot resolved to a spool that carries a colour, so a partially-mapped multi-colour print never silently loses the unmatched slots' colours (the 3MF value is kept). Shipped for both inventory modes in the same drop — built-in spools readSpool.rgba, Spoolman spools read the spool'sfilament.color_hex(fetched for tag-less slot-assignment matches). New helpers_spool_color_to_hex/_archive_colors_from_spoolsinusage_tracker.py, reused byspoolman_tracking.pyvia_apply_spool_colors_to_archive. Tests: 12 new intest_usage_tracker.py(hex normalisation, the all-or-nothing slot-colour rule across single/multi/partial/no-colour/AMS-fallback cases, and end-to-end that a#000000spool rewrites a#161616archive) + 4 intest_spoolman_tracking.py(the Spoolman-mode rewrite, empty/partial/missing-archive no-ops). 70 usage + Spoolman tracking tests green; backend ruff clean. -
Re-slicing across the single-nozzle / dual-nozzle boundary now actually works (#1493) — Re-slicing a model sliced for a single-nozzle printer (X1C, P1S, A1, P2S, …) onto a dual-nozzle printer (H2D / H2D Pro) — or vice versa — produced one of two BambuStudio failures: "Found G-code in unprintable area of multi-extruder printers" (the source's X1C-coordinate layout drops into the H2D's per-nozzle dead zone) or, on multi-color projects, a hard SIGSEGV inside the slicer's
ZFillerpolygon-clipping pipeline. An earlier release shipped a fail-fast400guard so the user got a clear message instead; this release lifts the guard and actually does the conversion — by forwarding the sidecar's existing--arrangeflag (it was already plumbed all the way to the CLI inorca-slicer-api/src/routes/slicing/slicing.service.ts:152; Bambuddy just wasn't surfacing it). Witharrange=trueBambuStudio repositions objects for the target bed and reconciles the embeddedproject_settings.configagainst the new printer, the same way the GUI's "Switch Printer" operation does. Empirically: a Mecha Mewtwo X1C archive that previously SIGSEGV'd on H2D now slices in 14.5s producing a 28 MB 3MF with valid H2D G-code, and the simple-test pair (#141 → H2D) which previously hit "G-code in unprintable area" also slices clean. Wired into_run_slicer_with_fallbackon a true class-crossing only (is_dual_nozzle_model(source) != is_dual_nozzle_model(target)) so single-printer slices preserve the user's deliberate layout. Threads through bothslice_with_profilesandslice_with_bundle(preset and bundle dispatch).guard_nozzle_class_reslicebecomes a kept-for-compat no-op; call sites inarchives.pyand the library re-slice route remain so external forks don't break their links. The earlieris_dual_nozzle_model()/DUAL_NOZZLE_MODELScentralisation stays put — the new cross-class detector reuses it. A separate related bug surfaced during testing: the SliceModal lets the user pick a filament profile per slot, but each plate only uses a subset of those slots. The unused-slot dropdowns get whatever default the modal serves up — and a heterogeneous default (e.g. ABS in slot 2 next to a PLA in the used slot 1) makes BambuStudio reject the slice with "the temperature difference of the filaments used is too large" (exit 194), even though the plate's G-code never touches the unused slot. Bambu validates every loaded filament for material compatibility regardless of which slots are actually used. Fix: a genericsubstitute_unused_plate_filamentshelper runs for both preset and bundle dispatch when a 3MF + plate are involved. It reads the source 3MF's per-plate extruder set via the existingextract_plate_extruder_set_from_3mf(the same logic that drives the modal's "not used by this plate" label) and overwrites any unused-slot entry with slot 1's selection before the slice. The per-slot array length stays intact (so source-3MF references still resolve), the loaded-filament set becomes materially homogeneous (so the validator passes), and the plate's G-code is unaffected because it never touched the unused slots in the first place. Fail-open everywhere — no plate, unparsable 3MF, single-filament list, or empty extruder-set parse all return the input unchanged. Applies to same-class slices too, which is why this lives outside the cross-class branch. Tests: 17 intest_slicer_3mf_convert.pycoveringextract_source_printer_model(returns canonical short codes, an integration check that the result feeds straight intois_dual_nozzle_modelend-to-end since the raw field is"Bambu Lab H2D"not"H2D", handles malformed/non-zip/empty inputs) andsubstitute_unused_plate_filaments(substitutes unused slots, no-op when all used / no plate / single filament / unparsable source); 3 new intest_slicer_api.py(preset and bundle paths both emit a multipartarrange=truefield when set, omit it entirely when default-false so the pre-#1493 wire shape is preserved); guard suite intest_library_slice_api.pyrewritten to assert the no-op semantics. Empirically validated end-to-end against the live sidecar: X1C source 3MF + H2D triplet +--arrangeslices clean for both the simple test pair and the multi-color Mecha Mewtwo statue. Card display fixes for re-sliced archives: BambuStudio CLI rarely emits a freshMetadata/plate_N.pngfor the sliced plate (slice writes the new gcode but leaves the preview slot empty — and--arrangedoesn't change that), so the previous per-plate preview the archive card relied on was simply missing on every re-sliced output. The slice route now picks the cover image in this order: (1) the source archive'sMetadata/plate_{N}.png— the GUI-rendered preview of the same plate, which is what the user expects to recognise on the card; (2) the sliced output's own per-plate render if BS did happen to write one; (3) the project-wide thumbnail underAuxiliaries/.thumbnails/(_middle.png/_small.png/_3mf.png) — the MakerWorld-style cover art that gets embedded at project import time. Without (1), the card always fell all the way through to (3) and ended up showing marketing art rather than a render of the actual print. A new_read_3mf_entryhelper extracts a single zip entry safely (no parser overhead, fails open on bad zips). TheThreeMFParserfallback chain was extended to include the Auxiliaries paths so the non-archive callers (library files, etc.) also benefit when neither per-plate variant is present. Second card gap: the re-sliced archive'sbed_typelived inextra_databut not on the top-levelPrintArchive.bed_typecolumn theArchiveCardactually reads — lifted it through, with a fallback to the source archive's value when the sliced output is sparse. 2 new tests intest_archive_service.py(Auxiliaries thumbnail fallback works, per-plate preview wins when both are present) and 4 intest_library_slice_api.py(re-slicedbed_typereflects the slicer's curr_bed_type and falls back to source on missing; re-sliced thumbnail prefers source's per-plate render over the Auxiliaries cover, and falls back to Auxiliaries when the source has no per-plate render either). 647 archive/library/slice tests green; backend ruff clean. -
Sliced files no longer report "0 g" filament usage — A slice result — and the re-sliced archive's card — showed
filament_used_g: 0(and0 mm) even for a real multi-hour print, while the print time came through fine. Bambuddy reads filament totals from the slicer sidecar'sX-Filament-Used-G/X-Filament-Used-Mmresponse headers, and some sidecar builds simply don't populate them. Fix:ThreeMFParser._parse_gcode_headernow also reads the slicer's own totals —; total filament weight [g] : …and; total filament length [mm] : …— from the produced 3MF's G-code header (verified against a real sliced output: 126.26 g / 41661.4 mm extracted correctly). Both slice-persist paths (slice_and_persistfor library files,slice_and_persist_as_archive) now fall back to those parsed totals when the sidecar header is 0, applying the corrected figure to the stored metadata, the archive'sfilament_used_gramscolumn, and the slice response. The G-code-header read is a fallback only —slice_info.configstill wins when it carries per-filamentused_g. Tests: 2 new intest_archive_service.py(_parse_gcode_headerextracts weight + length; absent header lines leave the keys unset). 36 archive-service + 23 slice-API tests green; backend ruff clean. -
A failed slice now opens an error modal instead of a toast that vanishes before it can be read — Slice failures surfaced through
SliceJobTrackerContextas a transient error toast, whichToastContextauto-dismisses after a flat 3 seconds. Now that a slice failure carries an actionable message — the slicer's own reason, e.g. "Some objects are located over the boundary of the heated bed." — 3 seconds is not enough to read it, let alone act on it. Fix: a newAlertModalcomponent (a small acknowledge-only modal: title, optional subtitle, message, single Close button; Escape / click-outside to dismiss — modelled onConfirmModalbut one button). On a failed slice job,SliceJobTrackerContextnow showsAlertModalwith the filename as subtitle and the slicer's reason as the body, instead of the error toast — the user dismisses it themselves. Successful slices keep the existing 3 s success toast; the persistent in-progress toast is still cleared on terminal state. Newslice.failedTitlekey translated across all 9 locales. Tests: 4 new inAlertModal.test.tsx(renders title/subtitle/message, Close button and Escape both fireonClose, subtitle line omitted when absent). Frontend build clean; i18n parity holds. -
Re-slicing for a different printer no longer silently produces a file for the original printer — Re-slicing an archive or library file for another printer (e.g. an H2D model re-sliced for an X1C) could hand back a file still sliced for the original printer, with no error.
_run_slicer_with_fallbackhas an embedded-settings fallback built for one narrow case — a 3MF whose--load-settingspath crashes the slicer CLI (#1201) — but itsexcept SlicerApiServerErrorcaught every sidecar 5xx, including the slicer running fine and rejecting the job for a real reason: e.g. exit 204 "objects over the boundary of the heated bed" (the model is laid out for the source printer's larger bed and doesn't fit the target's) or exit 194 "temperature difference of the filaments is too large". On those, Bambuddy retried withslice_without_profiles, which slices using the source 3MF's embedded settings — i.e. the original printer — and presented the result as success. The cross-printer slice itself works (CLI logs confirm the target bed{256,256,250}was applied); the fallback was masking legitimate rejections. Fix: a new_slicer_rejection_messagehelper detects the sidecar marker that means the slicer ran and rejected the job ("Slicing failed with error from slicer:") and extracts the slicer's own reason. Such failures now surface as a400with that reason (e.g. "Some objects are located over the boundary of the heated bed.") instead of falling back. The embedded-settings fallback is kept only for genuine CLI crashes, which carry no slicer error string. Net effect: a cross-printer re-slice either succeeds for the chosen printer or tells the user exactly why it can't — it never silently returns a file for the original printer. Tests: 5 new intest_library_slice_api.py— 4 unit tests for_slicer_rejection_message(extracts the bed-boundary and filament-temp reasons, returnsNonefor a generic CLI crash so that still falls back, handles empty/unrelated text) and an integration test asserting a slicer rejection fails the job with status 400 and the slicer's reason, with no fallback retry. The #1201 fallback test and the STL terminal-failure test (both using a generic error message) still pass unchanged. 23 slice-API tests green; backend ruff clean. -
Re-sliced archive now shows the printer it was sliced for, not the source's printer — Re-slicing an archive for a different printer (e.g. an X1C archive re-sliced for an H2D) produced a new archive still labelled "sliced for X1C".
slice_and_persist_as_archiveset the newPrintArchive.sliced_for_modeltosource_archive.sliced_for_model— blindly inherited from the source — even though the freshly-sliced 3MF embeds the actual target printer, whichThreeMFParseralready extracts intoparsed_metadata["sliced_for_model"](the new archive'sextra_dataJSON even had the correct value; only the dedicated column was wrong). Fix: readsliced_for_modelfrom the sliced output's parsed metadata, falling back to the source archive only if the output 3MF doesn't carry it — the sameparsed_metadata.get(...) or source_archive...pattern already used two lines up for filament type/color. Test: newTestSliceArchiveResliceModelintegration test re-slices an X1C-stamped archive with a mock sidecar returning an H2D-embedded 3MF and asserts the new archive is stamped H2D while the source stays X1C. 18 slice-API tests green; backend ruff clean. -
Self-hosted Inter font now actually loads —
/fonts/*.woff2was not served (#1460 follow-up) — The browser console loggeddownloadable font: rejected by sanitizerforinter-latin.woff2on every page load. The #1460 PWA fix added@font-facerules pointing at/fonts/inter-latin.woff2and bundled the woff2 files intostatic/fonts/, butmain.pyonly mounts/assets,/imgand/iconsas static directories — there was no/fontsmount. So/fonts/*.woff2fell through to the SPA catch-all and returnedindex.htmlwith200 OK; the browser's OpenType sanitizer then rejected the HTML-as-a-font. The woff2 files themselves are valid (verified — Inter variable, latin + latin-ext subsets). Fix: added a/fontsStaticFilesmount alongside the existing/imgand/iconsmounts. Additionally, the service worker had cached the bad response:sw.jslists the two font URLs inSTATIC_ASSETSandcache.addAll()treats the200 OKHTML as a successful fetch, so it storedindex.htmlunder the font URLs in the static cache and served it cache-first. The SWSTATIC_CACHEversion is bumped (v26→v27) so theactivatehandler purges the poisoned cache and re-fetches the real fonts on next load. The UI falls back to a system sans-serif until deployed, so there is no visible breakage — only the console warning. -
Library files now display the filename, not the embedded 3MF Title (#1489, reported by @needo37) — File Manager cards, search and sort keyed off
file_metadata.print_name, whichThreeMFParserlifts from the 3MF's<metadata name="Title">. That title is the in-app project title — generic"Exported 3D Model"for any Bambu Studio "Save As", a marketing title for a MakerWorld download — and almost never the filename the user actually saved. So a card forWhatever.3mfshowedExported 3D Model, and the only way to correct it was a rename round-trip (the Rename dialog's Save button is disabled while the name is unchanged, so the user had to rename to a different name and back). The slicer-output write path already droppedprint_namefor exactly this reason; the four other write paths that store parsed 3MF metadata onto aLibraryFiledid not — external-folder scan, managed multipart upload, the multi-file ZIP-upload branch, and MakerWorld import. Fix: a shared_without_print_name()helper stripsprint_namefrom library-file metadata, applied at all four import paths (and the slicer path switched to it, so there is one rule). ALibraryFile's display name is its filename; onlyPrintArchivecarries a realprint_name, and that is untouched. The now-redundant filename→print_namemirroring in the rename route is removed. A one-time data migration (_migrate_drop_library_print_name, idempotent, SQLitejson_remove/ PostgreSQLjsonbkey-removal branched onis_sqlite()) clearsprint_namefrom rows imported before the fix, so existing libraries correct themselves without the rename workaround. No frontend change —print_name || filenamenaturally yields the filename onceprint_nameis gone. Tests: 6 new intest_library_print_name.py—_without_print_name(strips, keeps siblings,Nonepass-through, no-op identity return, no input mutation, print-name-only →{}) and the migration (clearsprint_name, leaves siblings and metadata-free rows alone, idempotent). 109 library + dialect tests green; the migration's PostgreSQL branch additionally ran live against real Postgres during the integration-test app boot. Backend ruff clean. -
Camera: ffmpeg's stderr is now captured when an RTSP stream stalls instead of only when ffmpeg crashes (#1395, reported by @Tschipel) — A P2S support bundle taken on 0.2.5b1 (the per-model probesize fix already applied) showed the camera still failing: ffmpeg connects, stays alive 30+ seconds, emits zero JPEG bytes, the stream's 30 s
stdout.readtimes out, reconnect loop repeats — but with no ffmpeg stderr anywhere in the log to say why. Root cause was a diagnostic bug, not the camera path:_read_ffmpeg_stderrcalledprocess.stderr.read()(read-to-EOF). A stalled-but-still-alive ffmpeg — exactly the P2S RTSP failure mode — never closes stderr, so the read blocked until the 2 swait_fortimeout and returnedNone, discarding the banner + stream-analysis lines ffmpeg had already printed. ffmpeg's stderr was therefore captured only when it fully exited; the earlier "not enough frames to estimate rate" smoking gun was available only because ffmpeg crashed back then, and once the probesize bump turned the crash into a hang the diagnostic went dark. Fix:_read_ffmpeg_stderrnow drains stderr incrementally in bounded 8 KB chunks (64 KB cap), returning whatever ffmpeg has printed so far whether or not it has exited — so a hung stream is self-describing in the next support bundle. Additionally,generate_rtsp_mjpeg_streamnow logs the resolved per-modelprobesize/analyzedurationon the info-level "Starting RTSP camera stream" line (verifiable without debug logging), and the debug-level ffmpeg-command line logs the full argv with only the credential-bearing camera URL redacted, instead of hiding the entire command. No behaviour change to streaming itself — this makes the still-unresolved P2S RTSP stall diagnosable. Tests: 4 new intest_camera_stderr_summary.pycover_read_ffmpeg_stderrcapturing output from a running (un-exited, no-EOF) ffmpeg — the regression — as well as the exited case, the no-stderr-pipe case, and banner-only output summarizing toNone. 9 camera-stderr tests green; backend ruff clean. -
Camera diagnostic (stethoscope) was missing from the pop-out camera window (#1395, reported by @Tschipel) — The #1395 camera-diagnostic follow-up — stethoscope icon in the control bar, Diagnose button in the stream-error state,
CameraDiagnoseModal— shipped wired intoEmbeddedCameraViewer.tsxonly, the embedded camera mode. It was never added toCameraPage.tsx, the standalone window that opens at/camera/{id}whencamera_view_modeiswindow(the default). The reporter's support bundle had"camera_view_mode": "window", so they were onCameraPagethe whole time and genuinely could not see the stethoscope no matter how many container rebuilds or cache clears they tried — the JS bundle did contain thecamera.diagnosestrings (they come fromEmbeddedCameraViewer), but that component never renders in window mode. Switching to overlay mode made it appear instantly, exactly as the reporter found. Fix: ported the diagnostic intoCameraPage.tsx— theStethoscopecontrol-bar button (between Refresh and Fullscreen, matching the embedded viewer), a Diagnose button next to Retry in thestreamErrorblock, and theCameraDiagnoseModalrender. No new i18n keys —camera.diagnose.*already exist in all 9 locales. The backend per-model camera-profile fix from the same issue is view-mode-agnostic and already applied; this only makes the diagnostic reachable in the default window mode. Frontend build clean. -
Camera: P2S RTSP stream no longer drops every frame after the first (#1395, reported by @Tschipel) — With the stderr-capture diagnostic fix in place, a fresh P2S support bundle finally showed ffmpeg's reason for the stall:
frame=1 time=00:00:00.06 dup=0 drop=526 speed=0.0037x. ffmpeg connects fine and frames are arriving (thedropcounter climbs steadily, ~15/s) — but it emits exactly one output frame and the output clock freezes at 0.06 s. Root cause: the streaming ffmpeg command ends with-r 15, which puts ffmpeg in CFR (constant-frame-rate) mode — it drops/dupes input frames to hit 15 fps based on the source's timestamps. P2S firmware 01.02.00.00 sends an RTSP stream whose RTP timestamps don't advance (every frame is stamped ~0.06 s), so CFR sees every frame after the first as a same-timestamp duplicate and drops it. The browser gets one frame, then nothing → "connection lost", reconnect, repeat. This is why snapshot capture works on the same printer (that path has no-r, so no CFR conversion — timestamps are irrelevant) and why X1/H2 are unaffected (their firmware sends correct, advancing timestamps). The earlier "increase probesize" fix was real but had been masking this second bug — once ffmpeg got past the probe, the timestamp bug surfaced. Fix: the P2S camera profile gains-use_wallclock_as_timestamps 1as an ffmpeg input arg (via the existingextra_ffmpeg_input_argshook — no dataclass change, no other model touched). ffmpeg then rebuilds each packet's PTS from arrival wall-clock time, the output clock advances normally, and-r 15CFR conversion works as intended. Tests: 2 new intest_camera_profiles.py— the P2S profile splices the flag and value as an adjacent pair, and the default profile keeps an emptyextra_ffmpeg_input_argsso the override never leaks to X1/H2. 13 camera-profile tests green; backend ruff clean. -
A backend restart mid-print no longer duplicates the job in the archive (#1485, reported by @pwostran) — When the server running Bambuddy restarted during an active print, the running job was duplicated in the archive — and deleting the duplicate didn't help: every subsequent restart while the print was still running spawned a fresh one. Both support bundles confirmed it:
WARNING Found stale 'printing' archive 3 (age: 9:46:23), marking as cancelled and creating new archive→Created archive 4. On reconnecton_print_startfires (Bambuddy sees the printer running) and tries to re-attach to the existing archive inmain.py. The reliable match is bysubtask_id; the fallback is a name match plus — and this was the bug — a 4-hour staleness heuristic: a name-matchedprintingarchive older than 4h was assumed dead, markedcancelled, and a new archive created. Bambu prints routinely run far longer than 4h, so a genuine long print's live archive was destroyed and duplicated on every restart. Two root causes, both fixed. (1) Queue/scheduled archives never persisted a restart-stablesubtask_id. Bambuddy mints a per-job id (project_id/subtask_id/task_id) insidestart_printwhen it sends theproject_filecommand, and the printer echoes it back — but often not within the ~10s beforeon_print_startfirst fires, so the expected-print branch'sif subtask_id and not archive.subtask_idwrite got an empty value and the archive was left with no id. A later restart then had nothing to match on and fell through to the fragile name path. Fix:BambuMQTTClient.start_printnow records the minted id onlast_dispatch_subtask_id, andon_print_startfalls back to it when the printer hasn't echoedsubtask_idyet — so every dispatched archive persists a stable id and a restart resumes it by id, age-independent. (2) The 4-hour cutoff itself. Replaced with a progress-aware check: when a name-matchedprintingarchive is found on restart, the printer's current reported progress decides resume-vs-stale, not wall-clock age. Real progress (or unknown progress — printer offline) always resumes the existing archive. It is only treated as a stale leftover when the printer clearly shows a different, freshly-started print — under 1% progress on an archive more than 2h old, a state a real in-progress print is never in. The arbitrary 4h constant is gone. Net effect: a restart mid-print resumes the existing archive (started_at, energy, timelapse intact) instead of ever cancelling it and creating a duplicate. Tests: 2 new intest_bambu_mqtt.py(start_printrecordslast_dispatch_subtask_id, and updates it per submission); newTestStaleVsResumeintest_subtask_archive_resume.py— 6 cases pinning the progress-aware decision (long print mid-run resumes; barely-started long print resumes; ~0% + old archive is stale; ~0% + young archive resumes; unknown progress never cancels; the sub-1%/2h boundary). 472 print-start / MQTT / scheduler / dispatch tests green; backend ruff clean. -
File Manager no longer polls the printer over FTPS every 30 seconds while open (#1480, reported by @OscarsWorldTech) — The reporter's P1S churned through MQTT disconnect/reconnect cycles and timelapse downloads silently failed. The support bundle showed the real picture: during the churn windows, MQTT (
Connection stale - no message for 60.2s), FTPS (_ssl.c:1015: The handshake operation timed out) and the camera all timed out together and recovered together — the P1S's embedded controller saturating, not a network fault (wifi -44 dBm, Docker host networking). A visible contributor on Bambuddy's side:FileManagerModal.tsxran itsgetPrinterFilesquery withrefetchInterval: 30000, so every 30 s while the File Manager modal sat open it opened a fresh FTPS connection — full TLS handshake — to re-list the current directory. A printer's file list doesn't change on its own; it only changes on upload / delete (the modal's mutations alreadyinvalidateQueries) or when a print finishes. The blind 30 s poll was pure load, and on a fragile controller like the P1S it was enough to tip MQTT and FTP into the timeouts above. Fix: therefetchIntervalis removed. The listing still refreshes on modal open, on directory / tab change (the path is in the query key), after every upload / delete, and via the existing manual Refresh button — so nothing stops updating, the printer just isn't hammered. Reduces steady-state FTPS connection load while the modal is open from one handshake every 30 s to zero. 19 FileManagerModal tests green; frontend build clean. -
STL thumbnail generation failures now log a full traceback — Surfaced by the #1480 support bundle: every STL in the reporter's library failed thumbnail generation with
unsupported operand type(s) for /: 'str' and 'str', butgenerate_stl_thumbnail'sexcepthandler logged only the bare exception message — no traceback, no line number. The fault could not be reproduced from a clean STL across path shapes (#and spaces in the path),strvsPatharguments, or large meshes that exercisesimplify_quadric_decimation, so it is data- or environment-specific and the message alone is not enough to locate it.stl_thumbnail.pynow passesexc_info=Trueon that warning, so the next support bundle carries the traceback and the exact failing line. No behaviour change to thumbnail generation itself. -
Slicer: the Process / Filament dropdowns now filter by printer using the uploaded Slicer Bundles instead of guessing from preset names (#1325, reported by @IndividualGhost1905) — After the printer-preset pre-selection landed, the reporter found the Process Profile dropdown still showed a flat mix of
@BBL X1Cand@BBL P2Spresets with the printer set to X1C — P2S presets that should have dropped into the trailing "Other printers" group sat in the main list. Root cause:frontend/src/utils/slicerPrinterMatch.tsresolved each cloud / standard preset's printer by parsing the@BBL <model>suffix of its name against a hard-codedKNOWN_MODEL_CODESallow-list. That list (X1C, X1E, X1, P1S, P1P, A1M, A1, H2D, H2S) was missingP2S(andH2C,X2D), so every@BBL P2Spreset parsed to an empty model-code set,presetCompatibilityreturnedunknown, and the dropdown keepsunknownpresets in the main list (onlymismatchmoves to "Other printers"). It was a maintenance trap by construction: every new Bambu model silently broke filtering until someone edited the list. Fix: the name-suffix heuristic and both hard-coded model tables (KNOWN_MODEL_CODES,PRINTER_NAME_PATTERNS) are removed. Compatibility is now read from ground truth — the user's uploaded Slicer Bundles (.bbscfg). Each bundle is scoped to one printer and lists the process / filament presets it ships, so "process P works with printer X" holds exactly when some uploaded bundle for printer X contains P.buildCompatibilityIndexbuilds apresetName → {printer names}index per slot fromGET /slicer/bundles(already fetched by the modal), andpresetCompatibilityconsults it — still preferring an imported preset's owncompatible_printerslist when present. A newly released Bambu model is covered the moment its bundle is uploaded, with no code change. Presets no bundle covers stay in the main list (unknownis never hidden), so a user with no bundle imported sees the un-filtered list rather than a wrong one.printerPresetCode/presetModelCodesare gone;SliceModalpasses the bundle-derived index toPresetDropdown,pickProcessDefault, andpickFilamentForSlotin place of the old model code. Tests:slicerPrinterMatch.test.tsrewritten — 12 tests coveringbuildCompatibilityIndex(per-printer mapping, multi-bundle union,#user-clone-prefix stripping, empty-printer skip) andpresetCompatibility(imported-tiercompatible_printersexact match, bundle-driven match / mismatch / unknown, the #1325 P2S-into-X1C repro, no-bundles and no-printer-selected cases). 32 SliceModal tests green; frontend build clean; backend ruff clean. -
PWA: Bambuddy can now be installed as an app on Android, and the font is self-hosted (#1460, reported by @Soopahfly) — Reporter could install Bambuddy as a PWA on desktop but not on an Android phone (Pixel 9 Pro XL, failing in both Chrome and Edge). A thorough remote-DevTools trace confirmed the manifest was valid, all icons/screenshots present, and the service worker activated, running, and controlling the page — DevTools reported no installability blockers — yet no install prompt ever appeared. Two root causes, both Bambuddy-side. (1) No in-app install trigger. Chrome for Android removed the automatic install mini-infobar in Chrome 108 (2022); since then a site must either listen for
beforeinstallpromptand surface its own button, or the user must dig into the browser's ⋮ menu. Desktop Chrome still auto-shows the omnibox install icon, which is exactly why desktop "worked" and Android didn't. Bambuddy had nobeforeinstallprompthandler at all, so on Android there was no discoverable install path. Newfrontend/src/components/InstallAppButton.tsxcaptures thebeforeinstallpromptevent (callingpreventDefault()so the button is the single predictable entry point), re-fires it on click, shows a success toast on accept, and clears the captured prompt afterwards (a prompt can only be used once). It renders nothing when there is no pending prompt — already installed, unsupported browser, or iOS Safari (no programmatic install) — so it never shows a dead button. Added to both the expanded and compact sidebar-footer rows inLayout.tsx, next to the GitHub link. Newnav.installApp/nav.installAppSuccessi18n keys with real translations in all 9 locales (en/de/es/fr/it/ja/pt-BR/zh-CN/zh-TW). (2) Inter font loaded from the Google Fonts CDN.frontend/src/index.csspulled Inter via@import url('https://fonts.googleapis.com/css2?...'). For a local-first, offline-capable PWA this is wrong: it leaks a request to Google on every load, breaks the UI font when offline, and — as the reporter's trace showed — triggered CSP console errors (connect-srcdoesn't allowfonts.googleapis.com). The service worker made it worse: a request tofonts.googleapis.com/css2has path/css2(no.cssextension), so it missed the CSS branch, fell through to the catch-all HTML branch, and on failure was answered with the cachedindex.html— which is why the font request came back astext/html. Fix: the two Inter variable woff2 files (latin + latin-ext, one file covers every weight via the variable axis) are now bundled infrontend/public/fonts/and declared with@font-faceinindex.css, served same-origin. The service worker now (a) skips all cross-origin requests entirely — letting the browser handle them so a failed cross-origin fetch can never be answered withindex.htmlagain — (b) caches the font files inSTATIC_ASSETSand via a.woff2//fonts/match in the cache-first branch so the UI renders offline, and (c) bumps its cache version. With Inter self-hosted,fonts.googleapis.comandfonts.gstatic.comwere dropped from the SPA and gcode-viewer CSP directives inbackend/app/main.py(the/docsReDoc/Swagger CSP keeps them — that third-party UI genuinely loads Google Fonts). Frontend build clean, i18n parity green across 9 locales, backend ruff clean, 17 security-header tests green. -
Flow Calibration now actually runs when the print option is enabled (#1478, reported by @andreirusu99) — Reporter on an H2S saw poor extrusion around corners (classic too-high K factor); the printer's flow-dynamics calibration step never appeared in the pre-print checklist even with Flow Calibration toggled on in the Re-print dialog, while the same 3MF printed from Bambu Studio did calibrate. Root causes, both in the
project_filecommand built bystart_print(backend/app/services/bambu_mqtt.py): (1)extrude_cali_flagwas hardcoded to0. A BambuStudio request-topic capture from a real H2D (plus X1C and P2S captures) shows BambuStudio always sends1(run flow-dynamics calibration) or2(skip, reuse the stored PA value), paired withflow_cali, and never0— so the printer skipped calibration regardless of the toggle. (2) An earlier revision integer-encoded the calibration/leveling fields (timelapse,bed_leveling,flow_cali,vibration_cali,layer_inspect) for the H2 family (H2D/H2S/H2C/X2D) on the belief that H2 firmware required0/1; the same H2D capture disproves this — BambuStudio sends plain JSON booleans for every model. The "integer required" claim conflated these fields withuse_ams, which genuinely must stay boolean (H2D Pro reads an integeruse_amsas a nozzle index — the actual #1386 cause). Fix:extrude_cali_flagis now1 if flow_cali else 2, and the five calibration/leveling fields are sent as JSON booleans for all models, matching BambuStudio's wire format exactly. The H-family integer-conversion branch (is_h_family) is removed.use_amsis unchanged. This affects only the outbound print command; the virtual-printer inbound coercion of slicer integer0/1flags (#1403) is a separate path and untouched. Tests: intest_bambu_mqtt.py, the two tests that asserted the integer format (test_x2d_uses_integer_format_for_calibration_fields,test_h2s_keeps_integer_format_for_calibration_fields) are corrected to assert booleans and renamed; all three model tests (X2D/H2S/P2S) now also assertextrude_cali_flagis1when flow cali is on and2when off. 381 mqtt + virtual-printer tests green; backend ruff clean. -
OpenSpoolman-tagged spools are now selectable in the AMS-slot assignment picker (#1122, reported by @mithkr) — Reporter runs Bambuddy alongside OpenSpoolman. OpenSpoolman writes a generated NFC tag value into the Spoolman
spool.extra.tagfield; any spool it had tagged then never appeared in Bambuddy's "Select a spool" picker (LinkSpoolModal), so a spool that was physically unassigned could not be linked to an AMS tray. Root cause:GET /spoolman/spools/unlinked(backend/app/api/routes/spoolman.py) classified a spool as "linked, hide it" purely on presence of a non-emptyextra.tag. That was a stale proxy.extra.tagis only an RFID/NFC matching key — both Bambuddy and OpenSpoolman write a tag identifier there, for the same purpose — and its presence says nothing about whether the spool occupies an AMS slot. Bambuddy already has a dedicated ledger for that: thespoolman_slot_assignmentstable, whichmodels/spoolman_slot_assignment.pyitself documents as "the source of truth for Spoolman slot assignments". Fix:get_unlinked_spoolsnow decides assignability from that ledger — a spool is assignable iff its id is not inspoolman_slot_assignments— and ignoresextra.tagentirely. Verified the ledger is complete: bothlink_spool(manual link) and the AMS auto-sync (spoolman.pyslot-change persistence) upsert a row for every occupied slot, so nothing genuinely assigned can leak back into the picker.get_linked_spoolsandfind_spool_by_tagkeep usingextra.tagunchanged — those are genuine tag-match maps and are unaffected. Internal-inventory mode needs no parallel change: it stores tags in its own DB with no Spoolmanextracollision, so the OpenSpoolman conflict cannot occur there. Visible behavior change: a spool Bambuddy linked but whose slot assignment was later cleared now re-appears as assignable — correct, since it is genuinely re-linkable. Tests:test_spoolman_api.py—test_get_unlinked_spools_successnow asserts a spool with a non-empty OpenSpoolman-styleextra.tagbut no slot row still appears in the picker; newtest_get_unlinked_spools_excludes_slot_assignedseeds aSpoolmanSlotAssignmentrow and asserts that spool is excluded while a tagged-but-unassigned spool is included. 50 spoolman-API integration tests green; backend ruff clean. -
Missing-spool-assignment notification no longer false-fires on every Spoolman-mode print (#1473, reported and root-caused by @ojimpo) — Reporter on Spoolman mode (AMS 2 Pro, all four trays bound to Spoolman spools via the Assign-Spool UI) got a
print_missing_spool_assignmentnotification on every print start — 13 false positives in 7 days — each flagging trays that were correctly bound. He traced it precisely:backend/app/services/spool_assignment_notifications.pyqueried only the legacySpoolAssignmenttable, neverSpoolmanSlotAssignment. In Spoolman mode the legacy table is empty (bindings live inspoolman_slot_assignments, the source-of-truth since #1119), soassigned_global_trayscame back empty and every used tray was reported missing. Same class of miss as #1459 (the weight tracker also skippedSpoolmanSlotAssignment) and a feedback_inventory_modes_parity violation — a check present in both modes was wired only for legacy. Fix: the assigned-tray set is now the union of both tables —SpoolAssignmentandSpoolmanSlotAssignmentrows for the printer. Both exposeprinter_id/ams_id/tray_idin identical shape (verified againstmodels/spoolman_slot_assignment.py, whoseams_idrange 0-7 / 128-191 / 255 is fully covered by the existing_global_tray_from_assignment()), so the helper works on either unchanged. The union is strictly safe: it can only add assignments, so it never regresses legacy-mode behavior and never reports a genuinely-unassigned tray as covered. Scope note: this does not add RFID-extra.tagresolution (a tray bound purely via the loaded spool's RFID tag with no slot-assignment row) — that needs the Spoolman client and is a deeper change; the reported false positive is entirely covered by the union since the Assign-Spool UI writesSpoolmanSlotAssignment. Tests: 3 new intest_spool_assignment_notifications.py(the reporter's suggested cases) — Spoolman-only binding suppresses the notification; Spoolman partial coverage flags only the uncovered tray; mixed-mode (A1 legacy + A2 Spoolman) union covers all used trays. The test fake now routesexecute()by target table so either mode can be exercised; the existing legacy-mode test still passes unchanged. 4 notification tests green; backend ruff clean. Audit follow-up: a sweep of everySpoolAssignmentconsumer confirmed the other internal-mode-only users (usage_tracker.py,spool_tag_matcher.py,routes/inventory.py) are correct — internal and Spoolman modes have parallel implementations by design — but surfaced an asymmetry inroutes/settings.py: the Spoolman-mode toggle clearedSpoolAssignmentwhen switching on but never clearedSpoolmanSlotAssignmentwhen switching off, so stale Spoolman rows lingered. Harmless before, but now that the notification unions both tables those stale rows would wrongly count as "assigned" in internal mode and suppress a legitimate warning. Added the symmetric clear — switching back to internal mode now deletesSpoolmanSlotAssignmentrows, mirroring the existing on-switch behavior. 1 integration test intest_spoolman_slot_assignments.py::TestModeSwitchClearsAssignmentscovers it; 23 slot-assignment + 45 settings/slot tests green. -
Local Profiles: the search bar no longer disappears when a query matches nothing (#1470, reported by @pwostran) — Typing a query in Settings → Local Profiles that matched no preset made the search bar itself vanish, leaving the user unable to clear or edit the query without a full page refresh. Root cause in
frontend/src/components/LocalProfilesView.tsx: the search bar was gated on{totalCount > 0 && …}, andtotalCountis the sum of the post-filterfilaments/printers/processeslengths — so the moment the query filtered every column to empty,totalCounthit 0 and the search bar unmounted along with the columns. ThetotalCount === 0"No local presets yet" empty state then took over, which also misleadingly implied nothing was imported. Fix: addedhasAnyPresets, computed from the pre-filter preset counts (presets?.filament/printer/processlengths), and gated the search bar on that instead — it stays mounted as long as any preset exists, regardless of the query. The empty state is now split:!hasAnyPresetsshows the genuine "No local presets yet" + import hint, whilehasAnyPresets && totalCount === 0shows a new "No presets match your search" message (with a search icon) so the two cases are no longer conflated. NewnoSearchResultsi18n key added with real translations in all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). Tests: 1 new inLocalProfilesView.test.tsx— types a non-matching query and asserts the search bar is still in the DOM, retains the typed value, and the no-matches message renders. 10 LocalProfilesView tests green; i18n parity 4859 keys × 8 locales; frontend build clean. -
Failure Detection: the Status panel's Low / High thresholds now reflect the selected sensitivity (#1469, reported by @JohnMacOB) — Reporter changed the Sensitivity dropdown (Low / Medium / High) in Settings → Failure Detection and the "Low / High thresholds" readout in the Status panel never moved off
0.38 / 0.78, so the setting looked dead. Detection itself was always correct — the classifier atbackend/app/services/obico_detection.py:280usesclassify(score, settings["sensitivity"])with the real value, so warnings/failures triggered at the right confidence for the chosen level. The bug was display-only:ObicoDetectionService.get_status()computed the displayed thresholds with a hardcodedthresholds("medium")(obico_detection.py:324), ignoring the configured sensitivity.thresholds()isBASE × SENSITIVITY_MULT— low ×1.25 →0.48 / 0.98, medium ×1.0 →0.38 / 0.78, high ×0.75 →0.29 / 0.59— so the panel always showed the medium row whatever the user picked, making a working setting look broken. Fix:get_status()takes an optionalsensitivityparameter (default"medium", sothresholds()'s own unknown-value fallback still applies) and the/obico/statusroute — which already loads settings fresh and hassettings["sensitivity"]in hand — passes it through. The readout now updates the instant the dropdown change is saved (the frontend already invalidates theobico-statusquery on save), with no wait for the next poll cycle. Tests: 1 new intest_obico_detection.py::TestGetStatus—test_thresholds_reflect_configured_sensitivityasserts low > medium > high for both threshold bounds and that the default / unknown sensitivity falls back to medium. 47 obico unit tests + 5 obico API integration tests green; backend ruff clean. -
Printer serial numbers are normalized on input, and a stale connection that never receives a status report now logs an actionable hint (#1465, reported by @jmneely94) — Reporter's H2C connected over MQTT+TLS without error but every status field stayed
unknown(state, firmware, AMS, wifi); the P1S on the same instance worked. The report concluded the H2C firmware doesn't publish MQTT — but its own evidence disproves that: Bambu Studio LAN mode showed the H2C's live status, and Bambu Studio's device telemetry is MQTTdevice/<serial>/report(the "HTTPS API, not MQTT" claim in the report is incorrect — only file transfer/FTP and the camera stream are non-MQTT). A printer visible in Studio is publishing. Actual cause is layer-8: connect-OK + subscribe-OK + zero messages forever means Bambuddy subscribed to a topic with no traffic — the MQTT broker is the printer, it authenticates on the access code and SUBACKs a subscription to any topic string, so a wrong or mis-cased serial connects fine and silently receives nothing. The reporter's ownmosquitto_sub"verification" subscribed todevice/31b8c…/reportlowercase; MQTT topics are case-sensitive and Bambu serials are uppercase, so that test reproduced the mistake rather than validating the firmware theory. Bambuddy did nothing to guard against it:schemas/printer.pytookserial_numberas a bare string, the model stored it verbatim, andbambu_mqtt.pybuilt the topic asf"device/{self.serial_number}/report"with no.upper()/.strip(). Two hardening changes so this class of mistake self-heals or at least diagnoses itself. (1) Serial normalization: afield_validatoronPrinterBase.serial_numbernow.strip().upper()s the value (rejecting blank-after-strip), so a serial pasted in the wrong case or with stray whitespace produces the correctly-cased subscription topic.PrinterUpdatehas noserial_numberfield so the create path is the only entry point; existing DB rows are not migrated (forward fix — a mis-cased existing printer is corrected by re-adding it). (2) Zero-report diagnostic:BambuMQTTClientnow counts report-topic messages per connection (_report_messages_since_connect, reset in_on_connect, incremented in_on_messagewhenmsg.topic == self.topic_subscribe). Whencheck_staleness()fires its reconnect and that counter is still 0, it logs a one-shot WARNING (_zero_report_hint_loggedguards against spamming the 60-90s reconnect loop) telling the user the most common cause is a wrong/mis-cased serial and that the report topic is case-sensitive — turning a silent indefinite reconnect loop into something actionable in the log / support bundle. Known gap left intentionally: a printer that connects but sends literally zero messages (so_last_message_timestays 0) never tripsis_stale()at all — that grace behavior is #887-sensitive and out of scope here; the diagnostic covers the observed case where staleness does fire. Tests: 5 in newtest_printer_schema.py(uppercase, whitespace-strip, both, already-normalized no-op, blank rejected); 2 intest_bambu_mqtt.py::TestStaleReconnect(hint logs once when no reports received then stays silent on the next stale cycle; no hint when reports were received — a normal mid-session quiet gap). 659 printer/MQTT/scheduler tests green across the affected suites; backend ruff clean. -
Smart-plug "Auto Off after Drying" no longer kills the printer seconds into a drying cycle (#1462, reported by @Kyobinoyo) — Reporter on an X2D (firmware 01.01.00.00) set a 1-hour AMS dry and a short auto-off-after-drying delay, and the printer powered off almost immediately. The support bundle made it unambiguous: every
Sent drying command … duration=1was followed 3-9 seconds later byAMS 0 drying complete (dry_time 60 → 0)— the drying-complete callback fired seconds after drying started, not when it finished, arming smart-plug auto-off against a printer that was still drying (and potentially printing). The reporter's hypothesis was a missing print-state check; the actual cause is a false completion detection. Root cause — partial AMS-update merge dropsdry_time:backend/app/services/bambu_mqtt.pymerges partial AMS MQTT updates. The no-tray branch correctly preserved top-level fields ({**existing_unit, **ams_unit}), but the tray-bearing branch rebuilt the unit as{**ams_unit, "tray": merged_trays}— spreading only the new partial, neverexisting_unit. The printer constantly sends tray-bearing partials that carry no drying fields, so on every such updatedry_time(andinfo, which drivesdry_status/dry_sub_status) was silently dropped. The drying falling-edge detector then readint(ams_unit.get("dry_time") or 0)→ field absent →current = 0; withpreviousa real countdown value (60, 50, 52 in the reporter's log) theprevious > 0 and current == 0check fired a false "drying complete". Fix, two parts. (1) Tray-bearing merge branch now spreadsexisting_unitfirst —{**existing_unit, **ams_unit, "tray": merged_trays}— sodry_time,info, humidity, temp and any other top-level field a partial omits survive the merge, matching the no-tray branch. This also fixesdry_status/dry_sub_statusflapping in the UI on every tray update (same dropped-field bug, broader symptom). (2) Defence-in-depth in the falling-edge detector: it now only evaluates the edge whendry_timeis explicitly present (ams_unit.get("dry_time")not None) and parseable — an absent or unparseable value is skipped without touching_previous_dry_times, so a missing field can never be read as "drying finished" even if a future merge regression re-introduces a drop. Once detection is correct,on_drying_completeonly fires at real completion, so the auto-off timer arms when the user expects. Tests: 1 new intest_bambu_mqtt.py::TestDryingCompleteCallback—test_tray_only_partial_does_not_fake_completionpushesdry_time=60, then a tray-only partial with nodry_time, asserts no event fired ANDstate.raw_data["ams"][0]["dry_time"]still equals 60, then a realdry_time=0push fires the edge exactly once. 108 drying/AMS tests + 35 smart-plug-manager tests green; backend ruff clean. -
Scheduler: queue items with
force_color_matchfilament overrides now produce a correct AMS mapping at dispatch (#1437, fixed by external PR #1440 from @Person2099) — Contributor's own bug report and fix. He had a queue item withfilament_overrides: [{slot_id: 1, type: "PLA", color: "#CBC6B8", force_color_match: true}]andams_mapping: null, expecting Bambuddy to translate the override into a slot mapping at dispatch time. Instead the scheduler dispatched withams_mapping: nulland the P1S fell back to type-only AMS matching, picking the wrong-colour slot. Two-layer root cause he traced end-to-end. (1)backend/app/services/filament_requirements.py:69:extract_filament_requirements(file_path, plate_id=None)fell through to_collect_filaments(root, filaments)whose XPath./filamentonly matches direct children of<config>. Modern BambuStudio 3MFs wrap filaments inside<plate>elements, so this XPath returned[]on every modern multi-plate 3MF when no specific plate was targeted — which is the standard scheduler call shape for queue items without a pinned plate. The downstream "no AMS mapping" cascade ALL flowed from this empty filament_reqs result. Fix walks<plate>elements first, dedupes byslot_id(highestused_gramswins on ties — sane because BambuStudio slots are project-wide and the entry that extruded the most is the most representative for AMS planning), and preserves the old./filamentXPath as a fallback when no<plate>elements are present, so legacy 3MFs continue to parse unchanged. (2)backend/app/services/print_scheduler.py:792— defence in depth: even with (1) in place, edge cases exist where_get_filament_requirementscan still return None (3MF missingslice_info.configentirely, IO failure during ZIP extraction, etc). New_build_override_direct_mapping(force_overrides, status)helper kicks in at exactly that moment whenforce_color_matchoverrides are present — builds the requirement list directly from the overrides (slot_id,type,color, emptytray_info_idx) and delegates to the existing_match_filaments_to_slots()cascade against the printer's loaded AMS state. Wrong-colour slot credit via the cascade's type-only fallback is impossible-by-construction because the upstream_get_missing_force_color_slots()printer-eligibility gate at:590already requires an exact(type, normalised colour)pair to be loaded before the printer is even considered for the job, so by the time_build_override_direct_mappingruns the exact match is guaranteed in the loaded set and the cascade'sexact_matchbranch wins (colour normalisation is identical on both sides —tray_color.replace("#", "").lower()[:6]). Pref-only overrides (withoutforce_color_match) intentionally do NOT trigger the fallback — they keep the pre-PR "no mapping, printer picks defaults" behaviour, so the new fallback is strictly opt-in viaforce_color_match: true. Backwards-compat triple-checked: legacy 3MF format unchanged (preserved fallback path);plate_id != Nonebranch untouched (entire fix is inside theelseofif plate_id is not None);filament_overrides=None/[]/ no-force-entries all preserve the existingreturn Nonepath; malformed JSON infilament_overridesis caught by the existing try/except, logged, and still returns None. Tests (22 new across two files; all pass onpytest -n 30):backend/tests/unit/services/test_filament_requirements.py— 4 tests covering theplate_id=Nonemodern-format path, multi-plate collection, slot-dedup-by-highest-grams, and single-plate-modern-format.backend/tests/unit/test_scheduler_force_color_ams_fallback.py— 18 tests acrossTestBuildOverrideDirectMapping(single override matches AMS slot, empty AMS returns None, no colour match still produces a mapping length, multi-override produces multi-element mapping, external spool match yields global_tray_id 254,tray_info_idxis cleared) andTestComputeAmsMappingFallback(fallback used when reqs empty + force overrides present, fallback NOT used when no force_color flag, fallback NOT used when overrides None, normal path still used when reqs available, printer-status-unavailable returns None gracefully). 5079 backend tests + ruff + frontend build all clean post-merge; #1457/#1459/#1440 verified non-interacting (different services, different code paths, different timings). External-PR-checklist (per feedback_pr_changelog_required): contributor doesn't add CHANGELOG, this entry added by Martin post-merge. -
Spoolman: per-print weight reporting now works for tag-less spools assigned via the Bambuddy UI (#1459, reported by @Moskito99 — follow-up to #1119) — Reporter on Postgres + Postgres-backed Spoolman noticed that prints finished cleanly but the spool's remaining weight in Spoolman was never decremented. He correctly traced it: Spoolman's
extra.tagon his spool was empty, and writing a value in there by hand made weight tracking start working. Root cause is one missing fallback path. After #1119 introduced the localspoolman_slot_assignmentstable as the authoritative binding for tag-less spools (RFID is the binding for Bambu Lab spools, slot-assignment is the binding for generic / non-RFID spools), the Assign UI deliberately leaves Spoolman'sextra.tagfield empty for those spools — and after the #1457 cleanup we now actively clear it on re-binding to stop ghost links resurfacing in the hover card. That's the correct write-side behaviour. But the per-print weight tracker (backend/app/services/spoolman_tracking.py:_report_spool_usage_for_slots) only resolved the bound spool viaclient.find_spool_by_tag(spool_tag)— a single tag-lookup against Spoolman'sextra.tag. For tag-less spools that returns None and the tracker silently skipped the slot. The tracker never consulted the localspoolman_slot_assignmentstable that has the answer (verified:grep -n SpoolmanSlotAssignment backend/app/services/spoolman_tracking.pyreturned zero hits before this fix). So Bambu Lab RFID users got correct weight reporting (theirextra.tagis auto-populated by the AMS-synccreate_spoolpath atbackend/app/services/spoolman.py:1076), and generic-spool users on Spoolman saw weight tracking silently no-op — exactly the symptom Moskito99 saw. Fix adds a two-stage resolver inside_report_spool_usage_for_slots: stage 1 is the existingclient.find_spool_by_tag(spool_tag)(RFID and any RFID-equivalentextra.tagvalue), stage 2 is the new_resolve_spool_id_via_slot_assignment(printer_id, ams_id, tray_id)helper that queries theSpoolmanSlotAssignmenttable for(printer_id, ams_id, tray_id) → spoolman_spool_id. The (ams_id, tray_id) pair is derived from the slot's global_tray_id via the existing_global_tray_id_to_ams_slothelper — same translation used for fallback-tag generation, so external slots (global 254/255 → ams_id=255, tray_id=0/1) and AMS-HT slots (global 128+ → ams_id=global, tray_id=0) all resolve correctly. Stage-1-wins ordering is deliberate: when an RFID-bound spool is in the slot,extra.tagis the authoritative binding, even if the slot-assignment table happens to point at a different spool (legacy state). The resulting[SPOOLMAN] … via tagvs… via slot-assignmentsuffix in the success log makes it obvious which path resolved each slot, which support bundles will use to confirm the fix is live.printer_idthreaded through the three callers (_report_partial_usageG-code path,_report_partial_usagelinear path,report_usage) — they all already hadprinter_idin scope. Crucially,extra.tagis NOT auto-populated by this fix — that would re-introduce exactly the pollution #1457 cleaned up (deterministic fallback tags surviving across spool changes and surfacing stale spools in the hover card). The slot-assignment table is the source of truth for non-RFID bindings; Spoolman'sextra.tagis reserved for hardware RFID identifiers. Tests: 5 new inbackend/tests/integration/test_spoolman_tracking_slot_fallback.py: the bug repro (tag missing + slot-assignment present → use_spool by the slot-assignment's id); tag-match wins when both present (a regression that flips the resolution order would credit the wrong spool); skip-when-neither (no spool resolution attempted); skip-when-printer_id-not-supplied (legacy call shape stays inert); external-slot translation (global 254 → ams_id=255 tray_id=0 lookup works). Newpatch_async_sessionfixture routes the tracker's module-levelasync_sessionto the test engine so the in-testSpoolmanSlotAssignmentinsert is visible to the lookup. Postgres compatibility: verified — the lookup uses a plainselect(...where...).scalar_one_or_none(), no SQLite-only syntax. 642 spoolman/tracking tests + 5 new = 647 green; full backend suite 5065 green; ruff clean. -
Spoolman: AMS hover card and SpoolBuddy fill-bar no longer surface a stale spool after re-assigning a non-RFID slot (#1457, reported by @Menthe11) — Reporter on a P1S with generic (non-RFID) PLA saw two different spools rendered in the AMS hover card: the top "Spulen-ID / Im Inventar öffnen" link pointed at an almost-empty black PLA spool that had been in the slot weeks earlier, while the bottom "Zugewiesen" block correctly showed the full spool the user had just assigned via Spoolman. Root cause is two-layered. For non-RFID slots Bambuddy falls back to a deterministic per-slot tag (
hash(printer_serial) + ams_id + tray_id, 16 hex chars; seefrontend/src/utils/amsHelpers.ts:176). When a user runs Link UI on such a slot, that fallback tag is written to the Spoolman spool'sextra.tag— and the existing Link / Assign routes never cleared it from the previous holder when the user re-bound the slot to a different spool. The frontend's hover-card resolver atfrontend/src/pages/PrintersPage.tsx:3736(and the matching sites at:4137/:4452for HT and external slots) then preferred that stale tag-link over the user's explicit slot-assignment:linkedSpoolId: (trayTag ? linkedSpools?.[trayTag]?.id : undefined) ?? slotAssignmentForFill?.spoolman_spool_id. So when both layers existed and they disagreed, the stale spool won, and FilamentHoverCard's dedupe at line 377 couldn't collapse the two buttons because the IDs didn't match → two "Im Inventar öffnen" buttons pointing at different spools. The SpoolBuddy AMS page had the identical bug shape in two more spots:getSpoolmanFillForSlot()(the per-slot fill-percentage resolver, line 138) walked tag-link before slot-assignment, so the fill bar reported the old spool's remaining grams instead of the freshly assigned full one; and the slot-action picker's "Linked spool" / "Assigned spool" branches (line 760) showed "Linked spool" whenever a tag-link existed, regardless of whether a (more recent) slot-assignment also existed. Fix has two parts. (1) Frontend precedence swap at all five sites: slot-assignment is the user's most explicit, most recent action — it must outrank the tag-link, which is auto-populated and can be silently stale. With the swap, FilamentHoverCard's existing match-dedupe collapses both buttons into one pointing at the correct spool; SpoolBuddy's fill bar reads from the assigned spool's weight first; and SpoolBuddy's slot-action picker drops the stale "Linked spool" line entirely when a slot-assignment exists. (2) Backend hygiene so the stale state is never written in the first place: a new_clear_stale_tag_links(client, tag, keep_spool_id, log_context)helper inbackend/app/api/routes/spoolman_inventory.pyenumerates Spoolman spools and PATCHesextra.tagto JSON-empty ('""', the same wire shapeunlink_spoolalready uses so the read-side.strip('"')filter inget_linked_spoolsskips it) on any spool other than the one being bound that still claims the same tag. Wired intoPOST /spoolman/inventory/slot-assignments(computes the slot's deterministic fallback tag via the existingget_fallback_spool_tag_for_slothelper inspoolman_tracking.py— newly promoted to a public symbol that mirrors the frontend'sgetFallbackSpoolTag(serial, amsId, trayId)signature) andPOST /spoolman/spools/{id}/link(passes the literalspool_tagbeing bound — works for both RFID tags and fallback tags). Both are best-effort: per-spool patch failures and Spoolman enumeration failures are logged and skipped, never raised, so the assign/link path never wedges on a Spoolman hiccup. Existing assign-route tests stay green because their fixtures' Spoolman client mock already hadget_spoolsreturning[](or now does — fixture updated intest_spoolman_slot_assignments.py,test_spoolman_slot_concurrency.py,test_spoolman_slot_assignment_mqtt.py, and the link-route test fixture intest_spoolman_api.py). Tests (8 new inbackend/tests/unit/test_spoolman_stale_tag_cleanup.py): clears one other-spool while keeping the bound spool and unrelated-tag spool intact; case-insensitive match (the helper uppercases both sides becauseget_linked_spoolsalready does); empty-tag short-circuits without enumerating spools;keep_spool_idguards against clearing the spool being bound; Spoolman 5xx during enumeration is swallowed and the call returns 0; one per-spool patch failure doesn't abort the rest of the cleanup; the slot-fallback wrapper computes the right tag and clears it; empty serial returns 0 without enumerating. Backend: ruff clean, 581 spoolman tests + 8 new = 589 green. Frontend build clean. -
AMS drying popover no longer renders off the bottom of the viewport + diagnostic logging for the silent-drying-ignore bug (#1447, reported by @kleinweby) — Two distinct bugs in the same report, both shipped in this PR. (1) Popover positioning: reporter on P1S + AMS-HT couldn't see the Start button on the drying popover and worked around it via DevTools to confirm the popover was actually there, just clipped below the fold. Root cause in
frontend/src/pages/PrintersPage.tsx:3498 / :4011(two identical sites — one for the compact AMS row, one for the dual-nozzle layout): the flame-icon onClick computed popover position as a fixed{ top: rect.bottom + 4, left: Math.max(8, rect.right - 240) }with no viewport-overflow check. The flame icon sits at the bottom of the AMS info section on the printer card, so on most realistic viewportsrect.bottom + 4 + popover_height(~320px) > viewport.heightand the popover rendered partially or entirely off-screen. Fix extracts acomputePopoverPosition()helper infrontend/src/utils/popoverPosition.tsthat defaults to placing the popover below + right-aligned to the trigger (preserving the original visual layout), flips ABOVE the trigger when below would overflow AND above would fit, stays below in the degraded case where neither fits (popover taller than viewport — at least the top is visible and the user can scroll inside), and clamps the left coordinate so a trigger near either viewport edge can't push the popover off-screen horizontally either. Both PrintersPage callsites now go through the helper. (2) Diagnostic logging for the silent-drying-ignore: reporter's support bundle showed the printer receives everyams_filament_dryingcommand (multiple start / stop attempts onams_id=128, P1S 01.10.00.00 firmware), the printer ACKs each one, but the AMS info field never changes — drying neither starts nor stops on Bambuddy's request, while pressing Start on the printer's touchscreen worked immediately (so the hardware path is healthy and the LAN MQTT channel is delivering). The Bambuddy command JSON matches the format documented as working on H2D, all required fields are present, types match BambuStudio. Diagnosing the silent rejection needs the printer's actual response payload — whetherresult: "fail"and the specificreasoncode — butbambu_mqtt.py:918was only logging the response command name, not the body. The existingextrusion_cali_*/ams_filament_settingdebug path at:919-920was the template; this PR extends it toams_filament_dryingat INFO level specifically (not DEBUG like its siblings) because drying responses are rare — user-initiated only — and INFO ensures the body lands in support bundles by default without needing the user to bump log level first. Paired with a matching outgoing-side INFO log insidesend_drying_commandthat captures the full wire JSON, so the next support bundle has both halves of the conversation. The actual command-side fix can't happen without that data (no guessing — flippingclose_power_conflict: trueor otherwise mutating a field that matches the documented-working H2D shape could break currently-working installs). When kleinweby retries on this build and re-attaches a bundle, the rejection reason is visible and the command-side fix follows from real data. Tests (8 new in__tests__/utils/popoverPosition.test.ts): below-has-room places below; right-align to trigger; below overflows flips above; degraded case stays below; clamps right-edge and left-edge triggers; respects custom margin and gap. 276 backend service tests + frontend build clean. -
Stats: Print Activity heatmap buckets prints by local date, not UTC date (#1446, reported and root-caused by @needo37) — Reporter on CDT (UTC-5) noticed that prints finished in the local evening were jumping to "tomorrow's" cell on the GitHub-style contribution heatmap on the Stats page. He went through
frontend/src/components/PrintCalendar.tsxand identified the root cause: line 30 split the raw ISO string on'T'to get a YYYY-MM-DD key, which always returns the UTC date — but the cell tooltip (line 161) rendered viatoLocaleDateString(), which is local-tz aware. Same data, two renderers, only one was tz-correct. He confirmed with DB query: rows 29 and 30 stored as2026-05-18 ... UTCwere both localMay 17(20:46 CDT and 22:39 CDT), and the Archives → Print Log view formatted them correctly as May 17 viatoLocaleString()while the heatmap split them onto May 18 via the raw-ISO shortcut. The component had two more instances of the same shape that I caught while applying the fix: line 152 built the per-cell lookup key viaday.toISOString().split('T')[0](thedayDate objects produced by the calendar-generation loop are local-tz constructed vianew Date()+setDate, sotoISOString()shifted them back to UTC before the lookup — would have re-broken the join even after the bucketing fix), and line 154's "today" highlight comparison usednew Date().toISOString().split('T')[0]too (so at e.g. 23:00 CDT the heatmap would have ringed UTC-tomorrow's cell instead of local-today's). Fix adds alocalDateKey(input: string | Date): stringhelper infrontend/src/utils/date.tsthat wrapsparseUTCDate()and formats via the local-tz getters (getFullYear/getMonth/getDatewith two-digit padding), returning a stable comparable YYYY-MM-DD string. PrintCalendar.tsx uses it in all three spots — bucket key for input ISO strings, grid-cell lookup key, and "today" highlight — so the bucketing, the cell join, and the today ring all live on the same local-tz axis as the user's tooltip label. Backend stays UTC (PrintLogEntry.created_atunchanged); bucketing is a presentation concern and the browser already knows the user's tz. The reporter's broader point ("same fix needed anywhere else the frontend buckets timestamps to days") still has stragglers —StatsPage.tsx:55-84(computeDateRange) builds the dateFrom/dateTo strings for backend stats queries usinggetUTC*getters everywhere, so a "this week" picked at 23:00 local on Sunday in CDT sends UTC-Monday-based ranges to the backend; that's a separate, deeper bug because it also requires the backend to filter on a tz-shifted UTC range, and Bambuddy has no user-tz setting model today. Punted with alocalDateKeyhelper available for reuse when that work lands. Tests (5 new in__tests__/utils/date.test.ts): keys a local-evening Date to its local date (the bug repro), reproduces the reporter's row-30 case (a moment whose UTC date is "tomorrow" keys to local "today"), pads single-digit month/day, handles null / undefined / empty defensively, and accepts both Date and ISO-string inputs end-to-end viaparseUTCDate. 74 date-util tests green; frontend build clean. Tests are written tz-independently — they constructnew Date(2026, 4, 17, 22, 0, 0)via the local-time constructor form so they assert correctly regardless of which tz the CI runner happens to be in. -
Printers: Add Printer no longer hangs the container on P1S (#1445, reported by @psybernoid and confirmed by @thomassjogren) — Regression introduced in 0.2.4.2 by the
fix(printers): refuse to add a printer when the MQTT probe failschange (b51598ea). That commit added a pre-insert MQTT probe toPOST /printers/viaprinter_manager.test_connection()to catch mistyped access codes before persisting an empty card — but the probe had two compounding bugs that bit P1S specifically. First, a fixedawait asyncio.sleep(2)checkedstate.connectedexactly once at t=2s: P1S firmware's broker / TLS handshake routinely needs 3–5s to surface a CONNACK on a cold MQTT session (same firmware family that already has the documented "broker stops publishing but TCP stays alive" quirk atbambu_mqtt.py:3181), so the probe falsely rejected a printer that would have connected fine. Second, thefinally: client.disconnect()call ran synchronously on the asyncio thread —BambuMQTTClient.disconnect()ends in paho'sloop_stop()whichjoin()s the network thread, and if that thread was still mid-TLS-handshake to the slow P1S socket when teardown ran, thejoin()blocked the asyncio thread for as long as the handshake took to either complete or fail. POST/printers/therefore wedged, all other HTTP requests queued behind it, and Docker healthcheck timed out → user-visible symptom: "the container hangs." Reporter's workaround (downgrade to 0.2.4.1, add P1S, upgrade back) worked because 0.2.4.1's create-printer route skipped the probe entirely, so the row persisted immediately and the slow handshake happened on a fire-and-forgetconnect_printer()in the background. Fix swaps the fixed-sleep + sync-disconnect pair for a polling loop with an 8s budget (PROBE_TIMEOUT_SECONDS, configurable as class attributes for tests) that early-returns the momentstate.connectedflips True — so happy-path connects still finish in ~1–2s and slow brokers get the headroom they need — and movesclient.disconnect()toawait asyncio.to_thread(client.disconnect)so paho's thread-join can never block the event loop. The newconnect_printerfrom-existing-row flow that runs after a successful probe is unchanged (still fire-and-forget). The empty-card-report-prevention goal of the original probe stays intact: a genuinely wrong access code still results inconnected=Falseafter 8s of polling, the 400 withcode=printer_connection_failedstill fires, the row is still never persisted. Tests (2 new intest_printer_manager.py):test_test_connection_polls_and_returns_early_on_connectsimulates the P1S timing —connected=Falseat probe start, flips True ~500ms in — and asserts the probe early-returns in under 1.5s withsuccess=True(a regression that reverts to the fixed sleep fails this immediately);test_test_connection_disconnect_runs_off_loopmocks a deliberately-slow blocking disconnect (mirrors paho'sloop_stop()join semantics) and asserts (a)disconnectran on a thread other than the asyncio thread, and (b) a concurrent heartbeat coroutine kept ticking while disconnect was blocking the worker thread, proving the event loop wasn't stalled. The existingtest_test_connection_failuretest was patched to overridePROBE_TIMEOUT_SECONDSto 0.4s so the negative path still runs fast under CI. 6 printer-create integration tests still green; ruff clean. -
Stats: Failure Analysis widget no longer shows "Unknown" for archives classified after the fact (#1444, reported and root-caused by @needo37) — Reporter spotted that the Stats page "Top Failure Reasons" widget grouped failed prints as
Unknowneven after they'd been classified via the Edit Archive modal. He went through the data layer and identified the desync: twofailure_reasoncolumns exist —print_archives.failure_reasonwritten byPATCH /archives/{id}andprint_log_entries.failure_reasonread by the widget (backend/app/services/failure_analysis.py:88).PrintLogEntry.failure_reasongets captured exactly once at print-completion time (backend/app/main.py:3641) by copyingarchive.failure_reason— and at that moment the archive value is stillNULLbecause the user hasn't picked a reason yet. The Edit Archive modal's PATCH route then writes only toprint_archivesvia a genericsetattrloop, never touching the log entry → widget stays stuck onUnknownforever. The reporter confirmed the desync at the DB level (archive.failure_reason = 'Adhesion failure',print_log_entry.failure_reason = NULL). Fix mirrorsfailure_reasonandstatusfrom the PATCH payload to the most recentPrintLogEntryfor that archive (highestid). Latest-only becausearchive.failure_reason/statusalready reflect the latest run's outcome (each reprint clears the archive's reason atmain.py:2195and rewrites it at completion), so the Edit Archive modal is implicitly showing — and editing — the latest run; reprints of an archive that succeeded on the second attempt keep the original failed run's classification intact. Scoped to those two fields only —cost,print_name,printer_idetc are deliberately not mirrored because per-run values legitimately diverge from archive-level ones (e.g. partial-print cost on a failed run differs from the source archive's full-print cost, see_compute_run_filament_gramsatmain.py:596). Tests (3 new intest_archives_api.py): the bug repro (failure_reason mirrors), the status case (the second field the reporter flagged), and the reprint guard (only the latest of multiple entries gets touched, an earlier entry keeps its prior reason). 55 archives-API tests green; ruff clean. -
SpoolBuddy: spool ID surfaced everywhere a spool's identity is rendered + Write-Tag page honours Spoolman mode (#1439, reported + partially prototyped by @flom89) — Reporter buys filament in bulk and registers every individual spool in Spoolman at intake time (each gets a unique ID + a printed barcode that goes onto the physical roll when it's unboxed). When linking an NFC tag to one of those rolls in SpoolBuddy, the picker showed only material + colour + brand — so for ten identical "Black PLA" rolls every row looked the same. The user had no way to tell which physical spool they were about to bind the tag to; the original #1385 fix had surfaced the ID in Bambuddy's main UI (SpoolFormModal, FilamentHoverCard, the inventory-mode LinkSpoolModal) but the parallel SpoolBuddy components had been missed — they ship as part of the Bambuddy frontend repo under
frontend/src/components/spoolbuddy/andfrontend/src/pages/spoolbuddy/, not as a separate codebase. Part 1 — ID surface, seven spots:#<id>in muted small monospace added toLinkSpoolModal.tsx(the link-tag-to-spool picker — the reporter's primary use case),SpoolBuddyWriteTagPage.tsx(write-tag picker — reporter's second screenshot),AssignToAmsModal.tsxheader (single-spool context but disambiguating IDs help confirm the right roll was picked),TagDetectedModal.tsx(defined but unmounted today — kept consistent for future use),SpoolInfoCard.tsx(the found-tag panel on the right side of the dashboard — the "main screen" view),InventorySpoolInfoCard.tsx(matching inventory variant), andSpoolBuddyAmsPage.tsxAMS-slot assigned-spool block. All seven placements mirror the #1385 pattern (#<id>withshrink-0so truncation never hides the ID). Frontend-only — the ID was already on the Spool / InventorySpool API shape (spool.id); these seven files just weren't surfacing it. Part 2 — Spoolman-mode parity on the Write-Tag page: reporter then surfaced that the same page hardcodedapi.getSpools(false)regardless of inventory backend — so users in Spoolman mode (whose authoritative inventory is at Spoolman, not the internal table) saw spools they never created, and a successful tag write would bind the NFC tag to the wrong backend (the backend/spoolbuddy/nfc/write-tagroute is mode-aware via_get_spoolman_client_or_none, but the frontend was driving it with internal-mode IDs that don't exist on the Spoolman side). Fix follows the wrapper pattern InventoryPage uses (InventoryPageRouterat:445): page detectsspoolmanModefrom agetSpoolmanSettingsquery at the top and threads it through, withenabled: spoolmanModeReadygating the spool fetch until settings load so we don't burn a wrong-backend request during the initial render. Every API call in the page now branches onspoolmanMode— 6 sites: the main spool list, the NewSpoolTouchForm's autocomplete spool list, the untag flow (linkTagToSpoolvslinkTagToSpoolmanSpool— the Spoolman variant doesn't acceptdata_originsince Spoolman manages that), the K-profile save (saveSpoolKProfilesvssaveSpoolmanKProfiles), single-spool create (createSpoolvscreateSpoolmanInventorySpool), and bulk create (bulkCreateSpoolsvsbulkCreateSpoolmanInventorySpools— the Spoolman variant returns aSpoolmanBulkCreateResultenvelope vs raw array, handled with a duck-typed'created' in resultcheck that mirrorsSpoolFormModal's existing pattern). Same shape rule as feedback_sqlite_and_postgres_upfront / feedback_inventory_modes_parity: both modes ship in the same drop, nospoolmanMode ? undefined : ...UI gates. Tests: 3 new inSpoolBuddyWriteTagPage.test.tsx— the ID-visibility regression with two identical PLA-Red rolls IDs 42 / 43 (a future refactor that drops the ID span breaks it), plus two parity regressions (reads from internal inventory when Spoolman mode is OFFandreads from Spoolman when Spoolman mode is ON— the latter assertsgetSpoolsis NOT called when the user is in Spoolman mode, so re-hardcoding the internal endpoint breaks CI immediately). 11 WriteTagPage tests + 51 other SpoolBuddy component tests green (62 total); frontend build clean. -
FTP: P2S upload truncates / 426 "Failure reading network stream" on Python 3.13 (#1401, reported and root-caused by @iitazz) — Reporter on a P2S running firmware 01.02.00.00 saw every Bambuddy-initiated print fail with the printer's on-screen "unable to parse 3mf file" error ~30 s in; downloading the file back off the printer's SD card confirmed it was truncated at exactly 7 × 64 KB (clean chunk-boundary cut). Initial #1417 follow-up tightened our 426 handling so we'd surface upload failures instead of silently dispatching a print of a partial 3MF — but that only stopped Bambuddy from hiding the problem; the actual upload still failed. The reporter then dug into it with Gemini and identified the real cause: Python 3.13's default
ssl.create_default_context()negotiates TLS 1.3 when both peers support it, but the printer's vsFTPd build implements session reuse on the FTPS data channel against an old OpenSSL that doesn't tolerate TLS 1.3's asynchronous session-ticket model. The control-channel handshake completes, the data channel tries to resume the session, the resumption races, the data channel gets torn down mid-stream — first ~448 KB of bytes already in the TCP buffer land on the SD card, the rest never make it, printer's vsFTPd replies 426 instead of 226. Fix caps the SSL context'smaximum_versionto TLS 1.2 so session resumption is synchronous and the upload completes normally. Implementation follows the pattern just established bycamera_profiles.pyin the #1395 follow-up: a newbackend/app/services/ftp_profiles.pymodule with anFTPProfilefrozen dataclass (one field today,cap_tls_v1_2: bool = False) and a per-model registry. Default profile keeps the historical TLS-1.3 negotiation; P2S (display name + internal SSDP code N7) overrides withcap_tls_v1_2=True.ImplicitFTP_TLS.__init__gains a matchingcap_tls_v1_2kwarg;BambuFTPClient.connect()looks up the profile and threads the flag through. Deliberately scoped to P2S only — X1C / P1S / H2D installs that work today stay on the negotiated TLS 1.3; flipping a future model to the capped path is a one-line entry in_PROFILESwhen a new reporter surfaces the same symptom. Considered but rejected the reporter's second proposed change (revert manualtransfercmd+sendallback tostorbinary) — the stated rationale ("raw sendall breaks OpenSSL 3.x framing") is incorrect (CPython'sstorbinaryitself usessendallinternally; the actual socket-level behaviour is identical), the move to manualtransfercmdwas deliberate to dodge A1 hanging instorbinary's synchronousvoidresp(), and the #1417 SIZE-check escape for the "data is intact on the SD card despite the 426" race lives in the manual-transfer path — a switch tostorbinarywould lose that protection. Tests: 9 new intest_ftp_profiles.py(default profile doesn't cap; unknown / empty model falls back; P2S display name and N7 SSDP code both resolve to capped; lookup is case-insensitive; X1C / H2D / P1S / A1 stay uncapped; dataclass is frozen; integration test pins the wiring —ImplicitFTP_TLS(cap_tls_v1_2=True)actually setsssl_context.maximum_version == TLSVersion.TLSv1_2, guards against a future refactor that drops the profile→context wiring while keeping the registry looking correct). 87 existingtest_bambu_ftp.pytests still green; ruff clean. -
Library 3D preview: complex multi-part 3MFs no longer freeze the page (#1412, reported by @anthonyma94) — Reporter opened the 3D preview on a multi-color parted MakerWorld statue ("Mecha Mewtwo No AMS Multi Color Parted Statue") and the whole Bambuddy UI locked up — modal close button unresponsive, had to kill the tab. Root cause was in
frontend/src/components/ModelViewer.tsx: the 3MF parse runs entirely on the browser main thread (JSZip extract + DOMParser +getElementsByTagName('vertex')/('triangle')iteration +mergeGeometries), with no yield points between iterations. Bambu Studio's external-component shape (<component p:path="..."/>per part) compounds this — each component triggers another async file extract + DOM parse + vertex/triangle loop, all chained without surrendering control to the event loop between phases. For trivial models (the towel hook and Bambu scraper the reporter cited as working) the total wall-clock is short enough that the freeze isn't visible; for parted statues with dozens of components and high-poly meshes, the main thread is pegged for tens of seconds → browser shows "page unresponsive" and the close button can't fire. Stopgap that shipped here adds explicitnextTick()yields (await new Promise(r => setTimeout(r, 0))) at four hot spots: every 20 000 vertex iterations, every 20 000 triangle iterations, once per top-level<object>iteration, and once per<component>iteration. Parse wall-clock is unchanged — these yields don't make parsing faster, they just surrender the main thread back to the browser between batches so the modal can be closed, the page can scroll, and the loading spinner can actually render. Constants live next to the helper at the top of the file with a comment justifying the picked period (~5–10 ms of work per batch — fine-grained enough to keep frames flowing, coarse enough not to drown the loop in setTimeout dispatch overhead). The proper fix for this — moving 3MF parse + geometry build into a Web Worker so the main thread is never touched at all — is a tracked follow-up; this stopgap unblocks Anthony's reproduction case today without the worker refactor risk. The earlier close asinvalidwas a misdiagnosis (initial reading was that 3D preview only works on sliced files, which the reporter correctly disproved with a separate MakerWorld URL); reopened, fixed, lesson noted. Tests: 21 existingModelViewerModal.test.tsxtests stay green — the yields are inparseMeshFromDocandparse3MFwhich the tests mock around, and thenextTickhelper has no observable side effects beyond timing. Frontend build clean. -
Archives: timelapse auto-attach now works for VP-queue / dispatch prints (#1403 follow-up, reported by @pwostran) — Bambuddy uses a snapshot-diff strategy to pick the right MP4 off the printer's SD card after a print (Bambu printers in LAN-only mode don't sync NTP, so file mtimes are unreliable —
_scan_for_timelapse_with_retriessnapshots existing video filenames at print start and looks for any NEW filename at completion). The baseline-capture call was inline at the bottom ofon_print_start's new-archive branch only — the expected-archive branch (which queue / VP-dispatched / reprinted jobs take, anything registered viaregister_expected_print) exited at its ownreturnwithout ever snapshotting. So queue prints had_timelapse_baselines[printer_id]unset; the completion-time scan fell into its "take baseline now" fallback that snapshots the SD card after the new MP4 has already landed → the new file sits in the "baseline" set → no diff ever matches → auto-attach silently does nothing. The reporter'sbambuddy-support-20260518-185935.zipshows the failure verbatim:Using expected archive 3 for print (skipping duplicate)at18:41:10,Timelapse was active during print, scheduling auto-scan for archive 3at18:58:55, and[TIMELAPSE] Archive 3 has no printer, aborting(a separate bug already fixed by the printer_id-assignment commit in this same train) — andgrep -i baselineacross both his bundles returns zero hits, confirming the snapshot never ran. Fix extracts the inline baseline-capture into_capture_timelapse_baseline_at_start(printer, printer_id, logger)and calls it from BOTH branches ofon_print_start(mirroring the existing site in the new-archive branch with a matching call just before the expected-archive branch'sreturn). Helper is best-effort with atry / except Exceptionwrapping_list_timelapse_videos, so a transient FTP failure at print-start logs[TIMELAPSE] Failed to capture baseline at print start: …and the print proceeds — the completion-time fallback still kicks in (with its known limitation), behaviour matching what the new-archive branch had all along. Tests: newtest_expected_archive_path_captures_timelapse_baselinein the existingtest_print_start_assigns_printer_id_to_vp_archive.pypatches_list_timelapse_videosto return two pre-existing videos, runson_print_startthrough the expected-archive branch, and asserts_timelapse_baselines[1] == {"earlier_print_a.mp4", "earlier_print_b.mp4"}— a future refactor that removes the call from one of the branches now fails CI. The existing 2 regressions (test_expected_archive_path_assigns_printer_id_when_unset,test_expected_archive_path_preserves_existing_printer_id) plus 50 adjacent expected-archive / layer-timelapse / archive-filtering tests still green. The fixture clearing_expected_printsetc also clears_timelapse_baselinesnow so test isolation holds.
[0.2.4.2] - 2026-05-19
Added
-
Docker: opt-in system trust store for self-signed CA certificates (#1431, contributed by @WizBangCrash, requested in #1289) — Reporter runs a private LAN with self-signed certificates for internal HTTPS endpoints (his Home Assistant instance being the canonical case) and wanted Bambuddy to trust those CAs without disabling TLS verification end-to-end. Bambuddy talks to Home Assistant via
httpx.AsyncClient(backend/app/services/homeassistant.py:46) with defaultverify=True, which under httpx 0.28 means "usecertifi's CA bundle and nothing else" — so manually copying a CA file into the container had no effect. The fix is opt-in and container-side only: settingUSE_SYSTEM_TRUST_STORE=<any non-empty value>in the composeenvironment:block, combined with mounting the user's CA file(s) into/usr/local/share/ca-certificates, makes the entrypoint runupdate-ca-certificates --freshat startup andexport SSL_CERT_DIR=/etc/ssl/certs. httpx 0.28 explicitly honours that env var (_config.py:ssl.create_default_context(capath=os.environ["SSL_CERT_DIR"])), andupdate-ca-certificatespopulates/etc/ssl/certswith the Debian system CA bundle (Let's Encrypt, DigiCert, GlobalSign, etc.) plus the user-mounted CAs — so standard endpoints (api.github.com, MakerWorld, Bambu Cloud) keep working alongside the user's self-signed CA. Theca-certificatesapt package is added to the Dockerfile soupdate-ca-certificatesexists in the image. The feature is default-off — when the env var is unset the entrypoint logs a one-line "skipping system trust store update" and goes straight to the existing PUID/PGID chown path, so non-users see zero behaviour change. Fail-fast on misconfig: ifUSE_SYSTEM_TRUST_STOREis set but the container is running as non-root (the entrypoint can't write/etc/ssl/certswithout root), or/usr/local/share/ca-certificateshas no.crtfiles mounted, orupdate-ca-certificatesis missing from the image, or the trust-store rebuild itself fails, the entrypoint exits 1 with a clear error message rather than silently succeeding and leaving the user wondering why their HA connection still rejects the cert. Compose template update:docker-compose.ymlships commented-out examples for both the volume mount (/path/to/certs:/usr/local/share/ca-certificates) and the env var (USE_SYSTEM_TRUST_STORE=true) so the path from "I have a self-signed CA" to "Bambuddy trusts it" is two uncommented lines. Caveat worth flagging in docs: the feature requires the container to start as root so the entrypoint can runupdate-ca-certificates; users who pinuser: "1000:1000"in compose get the clear "not running as root" exit with the reason, but they need to switch to the default PUID/PGID-style invocation to use this. Companion wiki PR documents the setup walkthrough at maziggy/bambuddy-wiki#31. Hardware-only path (shell entrypoint change) so no automated test — verified by the reporter's local install. Post-merge polish: the fatal-exit branch's log line was relabeled from "warning: update-ca-certificates failed:" to "error: update-ca-certificates failed" to match severity and the surrounding error messages. -
Print labels: sort by colour as an alternative to spool-ID order (#1410, requested by @elit3ge) — Reporter asked for an option to order the printed label sheet by colour instead of spool number so a multi-colour roll of Avery sheets / box labels groups related colours together physically. The label-render backend (
labels.py) already honoured caller order — bothPOST /inventory/labelsandPOST /spoolman/labelspreserve the order ofspool_idsin the request body and pass it straight to the PDF renderer — so the fix is frontend-only.LabelTemplatePickerModalgains a smallsortModetoggle ("By ID" / "By colour") rendered as a chip pair next to the material-filter row. The "by colour" mode converts each spool'srgbato HSL and returns a[bucket, position]sort key: chromatic colours (saturation ≥ 0.1) go in bucket 0 ordered by hue 0..360 so the sheet reads as a continuous rainbow; achromatic colours (greys, blacks, whites, plus missing/invalid rgba) go in bucket 1 ordered by lightness so the neutrals trail the rainbow black → white. Multi-colour spools sort on their primaryrgba— the secondaryextra_colorsstripe still renders on the printed label but doesn't drive the sort, since multi-tone sorting would need a perceptual-distance model the use case doesn't justify. Stable tiebreaker on spool ID keeps identical-colour spools in a deterministic order across renders. The previous[...selectedIds].sort((a, b) => a - b)at submit time was forcing every PDF to ID order regardless of any frontend sorting — that's been replaced withsortedSpools.filter(s => selectedIds.has(s.id)).map(s => s.id)so the visible order flows through to the wire. Session-only state — toggle resets to "By ID" each time the modal opens, no persisted setting (label printing is a rare action and the user picks what they want every time). i18n: 3 new keys (inventory.labels.sortBy.{label, id, color}) translated across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW); parity check holds at 4852 leaves per locale. Tests: two new inLabelTemplatePickerModal.test.tsx— one asserts the "By colour" toggle reorders the submit payload to[Red, Ivory, Blue, Black](hue 0° / 33° / 240° then neutral with lightness 0) using the existing 4-spool fixture, the other guards the default "By ID" path so adding the toggle didn't quietly regress users who never click it. 17 modal tests green; frontend build clean. -
Camera: in-app diagnostic for "Connection lost" (#1395 follow-up) — Second step of the camera architecture overhaul. When the camera viewer hits its error state, a new Diagnose button next to Retry runs a staged check against the printer and renders the result inline: which stage failed, how long it took, and a translated remediation hint. Cuts off the "user opens a 'camera broken' ticket → wait days → ask for the support bundle → finally figure out it was their reverse proxy / LAN-only toggle / wrong access code" loop at the user's screen. Backend ships
backend/app/services/camera_diagnose.py(orchestrator) and a newPOST /printers/{id}/camera/diagnoseroute incamera.py. Stages: (1)tcp_reachable— opens a TCP socket to the camera port (322 RTSPS / 6000 chamber image) with a 3-second timeout; distinguishes timeout (tcp_timeout→ "printer not reachable, check IP/network/power") from refused (tcp_refused→ "camera port closed, check LAN-only and developer mode") from host-unreachable (tcp_unreachable→ "printer not reachable"). (2)first_frame— captures one JPEG end-to-end via the existingcapture_camera_frame_bytespipeline (15-second timeout, same code that powers/camera/snapshot); auth, RTSP handshake, and first keyframe collapse into one stage because the user-facing answer is the same regardless of which sub-layer failed. Live-stream shortcut: when a viewer is currently watching the printer's camera AND the buffered last-frame timestamp is fresher than 10 seconds, the diagnostic skips the real test and returnslive_stream_active_healthy— opening a fresh socket would kick the live viewer off on single-camera-connection firmwares (the #1348 reconnect-storm trigger), so we trust the real-world evidence instead. Response includes structured metadata for support triage:protocol(rtsp / chamber_image),port,profile(defaultor the model name with an override — currently onlyP2S), per-stage duration in ms, and the machine-readable summary code. Frontend addsCameraDiagnoseModal.tsxthat fires the API call on mount, renders one row per stage with green-check / red-X / grey-skipped icons, and shows the summary remediation message in a bordered banner styled by overall status. The metadata line at the bottom (protocol / port / profile) lets support triage ask "what does your modal say?" instead of "send the support bundle". A Run again button re-runs the diagnostic without dismissing the modal. EmbeddedCameraViewer error state grows the Diagnose button (kept "Retry" as the primary action; Diagnose is the escape hatch for users who can't see what's wrong). A small stethoscope icon also lives in the viewer's always-visible control bar between Refresh and Fullscreen, so pre-flight testing ("did my firmware update break the camera?", "is the camera up before I send a print?") doesn't require waiting for the stream to fail first. Also lifted the previously-hard-coded "Camera unavailable" / "Retry" strings intocamera.unavailable/camera.retryso the error UI is properly translated alongside the new keys. i18n: 16 new keys (unavailable,retry, plusdiagnose.{button,modalTitle,running,runFailed,retry,stage.*,summary.*,meta.*}) translated across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). German "Diagnose" is a real cognate — added toIDENTICAL_TO_EN_ALLOWED.derather than translated to a synthetic. Parity check holds at 4849 leaves per locale. Tests: 11 backend unit tests intest_camera_diagnose.pycover the live-stream shortcut (skip when fresh, run when stale), the three TCP failure modes (timeout / refused / OSError) → distinct summary codes, the first-frame stage (no-frame and capture-exception cases), the all-OK path, and the result metadata (P2S → P2S profile / rtsp / 322; A1 → default / chamber_image / 6000; X1C → default / rtsp / 322). 1 backend integration test pins the route's response shape end-to-end. 3 frontend tests inCameraDiagnoseModal.test.tsx(mounted → API call, failure → translated remediation, Run again → re-call). 5021 backend tests + 1905 frontend tests green; ruff clean; build clean; i18n parity clean.
Changed
- Inventory: AMS Filament Label Holder presets fixed and split into "small" and "large" variants (#1426, reported by @bsaunder) — Reporter (the same person who originally requested the labels feature in #809) discovered that the
ams_30x15preset's 30×15 mm dimension didn't fit any documented variant of MakerWorld model 752566, despite the preset advertising itself as designed for it. Two new presets replace it:ams_holder_74x33(matches the printable label STL bundled in the project) andams_holder_75x55(fits the cardstock-insert variant the reporter validated as "fits perfectly"). Both land in the roomy-layout branch (swatch + QR + multi-line text with brand / material / hex / spool ID) because the height crosses the 20 mm threshold — so the larger AMS holder labels carry the QR code back to/inventory?spool=<id>that the old 30×15 preset couldn't fit. The 30×15 preset is removed entirely; no DB migration needed because the preset name was never persisted (label printing is a one-shot action and the picker defaults to nothing). The legacy tight-layout code path in_draw_label_tightis kept as the safety branch for any future ultra-small preset (no shipped template uses it now). API change:POST /inventory/labelsandPOST /spoolman/labelsaccepttemplatevaluesams_holder_74x33andams_holder_75x55instead ofams_30x15. The Literal types inbackend/app/api/routes/labels.pyandfrontend/src/api/client.ts::SpoolLabelTemplatereject the old value at validation time, so any caller still scriptingams_30x15gets a 422 with a clear "valid values" message. i18n: replacedinventory.labels.templates.ams.{label,hint}withinventory.labels.templates.amsHolderSmall.{label,hint}andinventory.labels.templates.amsHolderLarge.{label,hint}— real translations across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW), the English-fallback strings on the old key are gone (feedback_translate_dont_fallbackrule). Parity check passes at 4856 leaves per locale. Tests:test_label_renderer.py::ALL_TEMPLATESupdated and the AMS-specific render test pins the small variant;test_labels.pyintegration test covers both new presets end-to-end;LabelTemplatePickerModal.test.tsxupdated to expect 6 template buttons in the grid (was 5), pin the newams_holder_75x55template value in the API call assertion, and verify both AMS variants are disabled when no spools are selected. 35 affected backend tests + 17 frontend modal tests green; full suite 5035 green; ruff clean; frontend build clean.
Fixed
-
Archives: "Scan for timelapse" no longer permanently disabled on VP-queue-dispatched prints (#1403 follow-up, reported by @pwostran and @enjoylifenow) — Reporters dispatched a print from a slicer via the VP print queue, the printer recorded the timelapse to its SD card (visible and downloadable via Bambuddy's file browser), but the archive UI's "Scan for timelapse" and "View Timelapse" actions stayed greyed out forever. Frontend gates both on
archive.printer_id(ArchivesPage.tsx:459); backend/archives/{id}/timelapse/scanalso 400s when the archive has noprinter_id. Root cause was inmain.py::on_print_start's expected-archive branch: VP-queue archives are created withprinter_id=Noneat queue-add time (we don't yet know which printer will run the job — the scheduler decides later for "Any P1S"-style queue items, and even for explicit-printer queue items the archive is created before dispatch). When the print actually starts andon_print_startlooks up the expected archive via_expected_prints, the branch updatedstatus,started_at, andsubtask_idbut never assignedprinter_id. So the archive stayedprinter_id=Nonefor the entire print and forever after — and every downstream UI / API path gated on it (timelapse scan + view, the printer filter on Archives, per-printer stats, success-rate cohort attribution) treated the archive as "unassigned." One-line fix in the expected-archive branch setsarchive.printer_id = printer_idwhen they differ, guarded against clobbering an already-correct value so library-file-based queue items (which create their archive with the printer pre-assigned at dispatch time) are unaffected. Two regressions in a newtest_print_start_assigns_printer_id_to_vp_archive.py:test_expected_archive_path_assigns_printer_id_when_unset(VP-queue archive withprinter_id=None→ promoted to the running printer) andtest_expected_archive_path_preserves_existing_printer_id(library-file archive withprinter_id=7stays at 7 when the same printer runs it — the branch is idempotent on correct data). The 2 existingtest_layer_timelapse_expected_archive.pytests + 19test_print_start_expected_promotion.pyregressions still green — the expected-archive branch's other side-effects (timelapse session, AMS mapping, status/started_at promotion) are unchanged. 5033 backend tests + ruff clean. Note about the queue-mode slicer-inheritance path itself: pwostran's specific complaint that the original #1403 fix "didn't work" was a misdiagnosis on his end — his support bundle proves Bambuddy correctly senttimelapse: trueto the printer and the printer recorded the video; his actual gap was this archive-attachment bug. The slicer-inheritance branch added in #1403 is a no-op for OrcaSlicer's "Send to print queue" flow because Orca only does the FTP upload and never sends aproject_fileMQTT command at upload time — so the path falls back todefault_*settings, which is the operative path for that workflow. Users who want a different per-print value still edit the queue item before starting (as pwostran did, which is why his dispatch carriedtimelapse: true). -
SpoolBuddy: NFC reader works again on Raspberry Pi 5 (#1424, reported by @flom89) — Reporter on a Pi 5 installed SpoolBuddy successfully but couldn't talk to the PN5180 NFC module (the gauge worked, so SPI hardware and wiring were fine). Manually commenting out
self._spi.no_cs = Truein the daemon restored communication; reporter wasn't sure whether removing it would regress Pi 4 installs. Root cause: Pi 5 uses the new RP1 southbridge and its kernel SPI driver (spi-rp1) doesn't accept theSPI_NO_CSioctl the same way the historical Broadcom driver on Pi 4 did — settingno_cs = Trueon Pi 5 either errors out or silently leaves the bus in a state where transfers don't complete. Safe to drop Pi-wide, not just Pi 5 — SpoolBuddy's PN5180 NSS line is wired to GPIO23 (manual chip-select handled by_cs_low()/_cs_high()around every transfer, because the kernel's default 5µs setup / 100µs hold timing doesn't meet the PN5180's spec). The hardware CE0 line (GPIO8) is not connected to the reader, so whether the kernel auto-toggles it duringxfer2()is electrically invisible to the PN5180. Theno_cs = Truecall was a "be polite to the bus" gesture that was always cosmetic on this hardware. Fix wraps the assignment intry / except OSErrorin bothspoolbuddy/daemon/pn5180.py(logs at debug level) andspoolbuddy/scripts/read_tag.py(silent — it's a diagnostic script with no logger). Try/except over a hard delete because Pi 4 installs that work today shouldn't see any behaviour change. README updated atspoolbuddy/README.md:23-28to drop the "spidev.no_cs = True resolves this" sentence in favour of explaining that manual CS via GPIO23 carries the timing on its own and that Pi 4 + Pi 5 are both supported. Hardware-only path so no automated test — verified by the reporter's bench test that commenting the line out restores reads. Ruff clean. -
Cover thumbnails: stop hammering FTP and GitHub when a print's 3MF isn't on the printer (#1420, reported by reporter) — Reporter on a P2S running 0.2.4.1 saw two log-flooding bugs trigger together once they started a print whose 3MF wasn't on the printer's FTP storage (typical SD-card-only print). (1) Cover endpoint had no negative cache.
GET /printers/{id}/covercached successful 3MF thumbnail downloads in_cover_cachekeyed by(subtask_name, view_key), but never recorded failures. When all 8 candidate FTP paths returned550 Failed to open file, the endpoint raised 404 without remembering that it just tried — and sincecover_urlstays populated on everyPrinterStatusresponse while state is RUNNING/PAUSE, every React-Query refetch and every component remount drove the frontend to re-fetch, replaying the same 8-path FTP fan-out roughly every few seconds. On the user's hardware the printer's single FTP socket was so busy with these doomed retries that it surfaced as camera-stream symptoms ("ffmpeg didn't terminate gracefully"). Fix adds a parallel_cover_404_cache: dict[int, set[tuple[str, str]]]that records the same(subtask_name, view_key)key on every 404 path — both the all-FTP-paths-failed branch and the 3MF-has-no-thumbnail-inside branch. On the next call for the same key, the endpoint short-circuits to 404 before even consulting FTP. The negative cache is cleared inclear_cover_cache()alongside the positive cache, whichmain.py::on_print_startalready calls — so when the next print starts (different subtask, or same subtask after a re-upload of a new file) Bambuddy retries fresh. (2) GitHub update-check had no backoff on 403 rate-limit. Onceapi.github.comreturned403 rate limit exceeded(typical when multiple Bambuddy instances or other tools share a NAT'd source IP and exhaust the unauthenticated 60-req/hr quota), the next call hit GitHub again immediately. Fix adds module-level_github_rate_limit_untilepoch-seconds plus three helpers inupdates.py:_seconds_until_github_unblocked(),_record_github_rate_limit(response)(readsX-RateLimit-Resetfrom the 403, falls back to a 1-hour pause when the header is absent or unparseable, and only extends the window — never shortens it via an out-of-order response), and_is_github_rate_limit_response(response)(status 403/429 withX-RateLimit-Remaining: 0, body-text fallback when proxies strip the header). Both call sites —GET /updates/checkand the in-app updater's_discover_target_release— short-circuit when the window is active; the route surfaces a structured{error: "GitHub rate limit reached...", retry_after_seconds: <int>}response so the SettingsPage UI can show a real wait time instead of an opaque "failed to check for updates". The "ffmpeg didn't terminate gracefully" warning line the reporter quoted is the standard SIGTERM → 2s wait → SIGKILL pattern incamera.py::_terminate_ffmpeg— RTSP/TLS streams routinely take >2s to drain and that warning fires for many users with no FTP issues; once the cover loop is silenced the resource pressure is gone, and the warning itself is cosmetic. Tests:test_cover_negative_cache_skips_repeat_ftp_fanoutintest_printers_api.pymocksdownload_file_try_paths_asyncto return False, calls the endpoint twice, and asserts the second call's FTP mock count is unchanged (the negative cache held);test_check_backs_off_after_github_rate_limitintest_updates_api.pypatcheshttpx.AsyncClientto return a 403 withX-RateLimit-Resetset 10 minutes ahead and asserts the second/updates/checkrequest never reaches httpx and surfacesretry_after_seconds > 0. 134 printers + updates integration tests green; ruff clean. -
Assign Spool: printer card refreshes immediately, no Force-refresh needed (#1414 follow-up, reported by @snozzlebert) — After assigning a spool via the modal, the Filament page Location column updated correctly but the Printer card kept showing "Empty slot (External Slot 1)" until the user manually pressed Force-refresh. The MQTT command itself was going through fine; the gap was on the client side.
AssignSpoolModal's twouseMutation.onSuccesscallbacks invalidated the inventory / slot-assignment queries (Filament page reads from those — correct) but never invalidated['printerStatus', printerId]and never issued apushallto make the printer republish its state. For Bambu RFID-tagged spools the printer echoes the newtray_typeover MQTT on its own and the websocket push would eventually surface it, but for non-RFID spools and A1 mini external slots (reporter's case) the firmware doesn't volunteer that state change, so the card sat on staletray_type: ""andgetEmptySlotKind()rendered the "Empty slot" path. Fix adds anudgePrinterRepublish()helper called from bothonSuccesspaths (internal-inventoryassignMutationandassignSpoolmanMutation): callsapi.refreshPrinterStatus(printerId)to issue the pushall (same call the Force-refresh button uses atPrintersPage.tsx:1844) and invalidates['printerStatus', printerId]so the refetch lands. Failures fromrefreshPrinterStatusare deliberately swallowed — the assignment itself already succeeded, and if the refresh nudge is offline the next regular poll / websocket update will catch up; we don't want to surface a misleading "assign failed" toast for a stale-cache cleanup that didn't go through. Mirrors the patternConfigureAmsSlotModalhas used since #1235 (line 5346) but with the extra pushall step because assign-spool affects firmware-side state where configure-slot affects only client-side preset mapping. Same fix covers both inventory modes thanks to the shared helper. Tests: newnudges the printer to republish after successful assignment (#1414)inAssignSpoolModal.test.tsx— picks a material-matching spool to bypass the mismatch-confirm dialog, clicks the Polymaker spool card to select it, clicks "Assign Spool", assertsapi.refreshPrinterStatus(7)was called.printerId=7(not the default 1) verifies the helper threads the prop value through correctly rather than hardcoding. The api mock at the top of the file gainsassignSpoolmanSlot,getSpoolmanSlotAssignments, andrefreshPrinterStatusso the existing 13 tests still pass alongside the new one. 14 modal tests + build clean. -
FTP: tolerate transient 426 from buggy printer FTP when the file is actually on the SD card (#1417 follow-up, reported by @enjoylifenow) — In the previous daily build, commit
1fac0276tightened the post-STOR confirmation handler inbambu_ftp.pyso that anyftplib.Errorfromvoidresp()(includingerror_temp 426"Failure reading network stream") would fail the upload outright. The goal was to stop Bambuddy from sending a print command for a truncated 3MF when the printer's FTP server explicitly told us the data stream was cut — exactly the scenario surfacing the user's earlier "unable to parse 3mf file" 30 s into a print. Reporter then confirmed (after running a filesystem check + reformat + power cycle, all clean) that the same install worked fine on v0.2.4.1 — proving that for the specific P2S firmware revision in question, the 426 is noise: the TLS data-channel close races the 226 confirmation, the server reports failure on voidresp, but the file did land fully on the SD card. The previous proceed-with-warning behaviour was accidentally correct for that firmware quirk. Reverting wholesale would re-introduce the silent-truncation bug, so instead narrow the rule: when voidresp raises anftplib.Error, immediately follow up with an FTPSIZEquery against the freshly-uploaded path. If the server-side size matches what Bambuddy just sent, the file is provably intact and Bambuddy proceeds with a warning (FTP STOR returned error_temp for X but file is intact on the printer (N bytes match) — proceeding). If the size doesn't match — orSIZEitself raises — the transfer was genuinely truncated (or the server is in too broken a state to be trusted) and the upload fails loudly with the error log path the previous round added (server size=... expected=...). Same logic is applied to bothupload_file()andupload_bytes()so the legacy A1-compatibility manual-transfer path is covered identically. Tests: the two existing regressions from the previous round (test_upload_426_data_stream_failure_returns_false,test_upload_bytes_426_data_stream_failure_returns_false) are renamed and split:test_upload_426_with_intact_file_proceeds(SIZE matches → returns True, the reporter's case),test_upload_426_with_truncated_file_returns_false(SIZE smaller than expected → still fails, the original bug we don't want to regress),test_upload_426_with_size_check_failing_returns_false(SIZE itself raises → assume the worst), plus parallel coverage forupload_bytes(). The intact-file tests have to injectSIZEexplicitly because the pyftpdlib mock only flushes the on-disk file after a clean voidresp — which doesn't happen when we monkeypatch voidresp to raise — and the docstring spells that out for future readers. 87 FTP unit tests green; ruff clean. The View-Timelapse-greyed-out behaviour the original #1417 report flagged stays untouched here — once the reporter confirms their upload reliability is back, that diagnosis continues on a healthy install. -
AMS: physically-empty slots now consistently report state=9 (#1322 follow-up, diagnosed by @RosdasHH) — Reporter dug into the BambuStudio source and pointed out that our previous fix only caught the narrow
{"id": N}bare-payload shape, which Bambu firmware only sends right after a printer restart. In steady-state operation — including the more common post-Reset-Slot path on P1S and the A1 Mini BMCU — firmware sends a populated payload with stale fields and signals emptiness via thetray_exist_bitsbitmask instead. Bambuddy already parsed that bitmask atbambu_mqtt.py:1758(slot_exists = (tray_exist_bits >> global_bit) & 1) and used it to wipe staletray_type/tray_color/tag_uidfields, but never promoted the slot'sstate. So downstream readers — the API serializer atprinters.py:457, thetray_state in {9, 10}short-circuit ininventory.py:1358, the AMS card — all sawstate: nulland had to guess from absent payload fields. Reporter's API screenshot showed exactly that shape:state: null, tray_color: null, remain: 0, .... Fix lifts atray["state"] = 9assignment to the outerif not slot_existsbranch (was nested inside the stale-data-clear branch), so the bitmask path now writes the canonical "no spool" state for every empty slot regardless of whether stale fields are present. Hard-typed asint9, not string"9"— the downstream check atinventory.py:1358usestray_state == 9(notin {"9", 9}), so a string would have silently missed and the reporter's deadlock would have come right back. The previous narrow heuristic inprinter_manager.py:797-798(thelen(tray) == 1 and "id" in trayshape detector) stays in place as belt-and-suspenders for the post-restart bare-payload edge that bypasses the AMS merge — costs nothing and protects against any MQTT path that doesn't flow through_handle_ams_data. Thestate: nullsurfacing on the API resolves automatically sinceprinters.pyreadstray_data.get("state")directly. Tests: two new intest_bambu_mqtt.py::TestAMSDataHandling—test_tray_exist_bits_promotes_empty_slot_to_state_9exercises the steady-state populated-payload path (slot occupied → bitmask flips bit 1 to 0 → state=9, type-asserted as int; loaded sibling slot keeps its state=11 unchanged);test_tray_exist_bits_does_not_change_state_on_loaded_slotspins the negative path (bitmask bit=1 with state=3 leaves state untouched — transitional firmware states like "unloading" don't get corrupted). The twoprinter_manager.pyregression tests for the narrow heuristic (test_bare_tray_emulates_state_9,test_populated_payload_with_empty_state_3_is_not_promoted) stay green — that path is unchanged. 397 mqtt+printer-manager unit tests + 50 inventory/Spoolman slot-assignment integration tests = 447 affected tests green; ruff clean. UI: visual distinction between physically empty and unconfigured slots (in the same drop). With the data layer now consistent, the AMS slot card surfaces what Bambuddy actually knows about each empty slot, without overclaiming. New helpergetEmptySlotKind(tray)inPrintersPage.tsxreturns"physical"(state ∈ {9, 10} — firmware positively confirmed no spool),"reset"(any other empty state — could be a user-cleared assignment, mid-unload, or just a slot the firmware hasn't reported on yet), ornull(loaded). The inline label below the slot circle readst('ams.slotEmpty')("Empty") uniformly for any empty slot (regular AMS, HT, external) so users get a consistent label everywhere — the previous version only said "Empty" for firmware-confirmed state=9 slots and fell back to an em-dash otherwise, which surfaced as "Empty" for regular AMS slots but "—" for HT AMS (skipped by the bitmask loop) and external trays (separate MQTT path entirely). The state distinction now lives only on the border and hover card where it doesn't surprise.FilamentSlotCirclegains anemptyKindprop that picks a quieter dashed border colour for unconfigured slots (#3d3d3dvs#666), so the visual hierarchy reads "loaded > unconfigured > physically empty" at a glance even though the inline text only differentiates physical from everything else.EmptySlotHoverCardgains akindprop and switches the hover label betweenams.emptySlot("Empty slot") for physical and the newams.emptySlotReset("No filament assigned") for everything else — also rewritten from the original "Slot reset — no spool assigned" for the same overclaim reason. All three slot-render call sites inPrintersPage(regular AMS grid, HT AMS single-slot, external spool tray) now compute and pass the kind. i18n: 2 new keys (slotEmpty,emptySlotReset) translated across all 8 locales; parity at 4854 leaves. New test#1322: empty slot kind is "physical" when state=9 and "reset" otherwiseinPrintersPage.test.tsxreuses the existingphase13EmptySlotPropsmock to capture thekindprop across a 4-slot fixture (state=9 / state=3 / state=null / loaded) and asserts each variant flows through. 71 PrintersPage + FilamentHoverCard tests green; build clean. -
Stats page: Filament Used, By Time, and Success Rate now agree with Total Consumed and Total Prints (#1390 follow-up, reported by @IndividualGhost1905) — After the archived-spool fix shipped the reporter confirmed it worked and gently flagged the round Bambuddy had explicitly postponed: Quick Stats
Filament Used/Filament Costdidn't matchTotal Consumedon the Inventory page; Printer StatsBy Timedidn't match Quick StatsPrint Time; the success-rate gauge percentage didn't relate to theTotal Printscount shown right above it. Three independent root causes, fixed together. (1) Filament Used vs Total Consumed._compute_run_filament_gramsinmain.pyshort-circuited to the slicer estimate forstatus == "completed"even when inventory had measured the actual AMS weight delta — the comment on the old test literally said "the print is done, so the full estimate is the right answer." That made Stats and Inventory two different sources of truth: Stats showed slicer-estimate grams, Inventory showed AMS-tracked grams, and the two numbers naturally diverged (slicer estimates are typically a few percent off real consumption). Fixed by reordering the helper so the tracked spool delta (sum ofusage_results[].weight_used— same source that drives the per-spoolweight_usedcounter behind Total Consumed) takes priority for every status. The slicer estimate stays as the fallback when no inventory was tracked for the print, and the partial-progress scale stays as the fallback for failed/cancelled/stopped with no tracker — so the existing #1378 partial-aware behaviour is preserved. The_run_costblock right next to it already used this priority order, so cost was always tracker-first; onlyfilament_used_gramswas inconsistent. New prints now record what was actually consumed, so Stats and Inventory show identical numbers. (2) Printer Stats By Time vs Quick Stats Print Time./archives/slimonly populatedactual_time_secondswhenstatus == "completed". For failed/cancelled rows the field stayed null and the frontend (StatsPage.tsx::PrinterStatsWidget) fell back toprint_time_seconds— the slicer's estimated full-print duration, which is the wrong number for a print that failed at 15% progress. Quick Statstotal_print_time_hoursalready counted every event's elapsedduration_secondsregardless of status, so the two halves of the page disagreed by the (estimate − actual-elapsed) gap on every non-completed event. Dropped thestatus == "completed"gate in the slim row'sactual_time_secondscomputation; failed/cancelled events now report their measured elapsed time and the frontend'sactual || print_timefallback chain only ever falls through to the slicer estimate for events with no measured duration at all. (3) Success Rate %. Formula wassuccessful / (successful + failed), denominator excludingcancelled/stopped/ any other status. Combined with the visible "Total Prints: N" label right above the gauge, that produced confusing numbers: 4 successful, 0 failed, 48 cancelled showed 100% out of an apparent 52 prints. Switched tosuccessful / total_prints— straightforward "what fraction of all attempts succeeded", matches the count the user reads from the widget header. The widget'sstatsprop already exposedtotal_printsso no type changes were needed. (4) Records widget "Longest Print" — knock-on from (2). Before (2),actual_time_secondswas null for non-completed rows so the Records widget'sfindMax(a => a.actual_time_seconds)implicitly only ranked successful prints. Once (2) populated the field for failed/cancelled events too, an aborted 25-hour print would have outranked a genuinely successful 18-hour print as "Longest Print" — a real semantic regression. Added astatus === 'completed'gate on the longest getter only, restoring the pre-fix semantic. The other two records (Heaviest Print, Most Expensive) already included non-completed events viafilament_used_gramsandcostand intentionally stay as-is, since those values are populated by the partial-progress / tracker logic in_compute_run_filament_gramsand were never gated on status. Out of scope — backfilling the 52 historical events on the reporter's database:PrintLogEntry.filament_used_gramsis already baked in as the slicer estimate for older prints and we don't store per-event AMS deltas separately to backfill from. The reporter said upfront she'd "reset all statistics and start over" to track new prints cleanly, so this lands without a migration. Similarly the Failure Analysis 30-day default (a separate divergence the agent surfaced while mapping the page) wasn't part of the reporter's complaints and stays untouched. Tests:test_run_filament_helper.py::test_completed_returns_estimate_even_when_tracked_differswas renamed and inverted totest_completed_prefers_tracked_over_estimate— it now pins the new contract (completed + tracker → tracker value), guarding against a future "trust the estimate again" refactor. All 13 existing helper tests still green; the helper's contract changed in exactly one place and the rest (no-tracker fallback, partial-progress scaling, multi-slot summation) is unchanged.test_archives_api.py::test_slim_actual_time_null_for_failedwas renamed totest_slim_actual_time_for_failed_includes_elapsedand inverted — same pattern, the old assertion is now the regression check.StatsPage.test.tsxgains two:uses total_prints as denominator so cancelled/stopped events count (#1390)(40 successful / 20 failed / 40 cancelled-or-stopped = 40%, matches Total Prints: 100, where the old formula would have shown 67%); andLongest Print excludes failed prints (#1390)pinning that an aborted 25-hour run can't outrank a successful 8-hour print as the Longest Print record — protects against a future refactor that removes the new status gate insidefindMax. 33 StatsPage tests + 66 archive-API + run-filament tests green; frontend build clean. -
Inventory: "Total Consumed" now includes archived spools' usage, and the eraser works on archived too (#1390 follow-up, reported by @IndividualGhost1905) — After the original #1390 fix shipped, the reporter noticed that archiving a spool with consumed weight quietly subtracted that weight from the "Total Consumed" stat at the top of the Inventory page, and un-archiving put it back. Total Consumed is a running counter (lifetime usage since the last reset), not a current-inventory snapshot, so a spool's recorded prints SHOULD stay in the total even after the user archives the physical roll — otherwise the reset baseline becomes meaningless and the running total walks down as users tidy up their inventory. Root cause was a stats loop in
InventoryPage.tsxthat gated every aggregate (totalConsumed, totalWeight, lowStock, byMaterial, activeCount) behind a singleif (s.archived_at) continue;check. Fix splits the loop sototalConsumedis computed BEFORE the archived-skip and the other aggregates after it, matching the semantic difference between "running counter" and "currently-available inventory". Two adjacent regressions the reporter also surfaced are fixed in the same pass: (a) the per-spool eraser button in the inventory card grid used to require!spool.archived_at && spool.weight_used > 0— archived spools had no way to zero their tracking counter without first being un-archived. Thearchived_athalf of that gate is gone; theweight_used > 0half stays. (b)activeSpoolIds, the target list for the "Reset all usage" bulk action, used to filter out archived spools — so a Reset-all click left archived consumption stuck in the (now-corrected) totalConsumed total. Renamed toresetableSpoolIdsand broadened to include archived, so a Reset-all genuinely zeroes the stat in one click. Backend reset endpoints already accept archived IDs (bothinventory.py::reset_spool_usageand the Spoolman mirror), so this is frontend-only. Inventory-mode parity holds (both modes shareInventoryPage). i18n: 8 tooltip/confirm strings retranslated across all 8 locales — the "every active spool" / "all {{count}} active spools" wording was now incorrect (archived included), so each locale'sresetAllUsageTooltipdrops "active" andresetAllUsageConfirmmakes the archived-inclusion explicit ("(archived included)" / "(incluindo as arquivadas)" / "(含已归档)" etc.); parity holds at 4852 leaves. Tests: a newInventoryPageArchivedConsumed.test.tsxwith a 2-spool fixture (active 300 g + archived 500 g) pinstotalConsumed = 800gafter the fix and asserts the "Reset all spool usage" button stays rendered; a future refactor that re-introduces the archived-skip drops the assertion to "300g" and CI fails. 13 InventoryPage tests + i18n parity + build all green. -
P2S camera: relaxed ffmpeg probe settings so the RTSP stream actually locks (#1395 follow-up, reported by @Tschipel) — Reporter on a P2S running firmware 01.02.00.00 saw the camera connect for a few seconds and then time out, repeating. P1S on the same install worked fine because P1S uses the chamber-image protocol (port 6000), not RTSP — different code path. The P2S RTSP path was running ffmpeg with
-probesize 32 -analyzeduration 0, tuned for X1/H2 fast startup. The P2S's slower keyframe pacing means ffmpeg can't lock onto the stream within 32 bytes; its own stderr literally says "Stream #0: not enough frames to estimate rate; consider increasing probesize". After ~2 s ffmpeg gives up, Bambuddy reconnects, the cycle repeats. The naïve "just bump probesize" patch would regress every other RTSP-capable printer, so the fix is also the first step of the camera architecture overhaul: per-model tuning lives in a newbackend/app/services/camera_profiles.pyregistry instead of hard-coded module constants.CameraProfiledataclass holds the previously-global knobs (probesize,analyzeduration,rtsp_reconnect_max,rtsp_reconnect_delay, plus anextra_ffmpeg_input_argshook for future per-model flags);get_camera_profile(model)returns the model's profile or the default. The default profile preserves the historical X1/H2 fast-startup values verbatim — X1, X1C, X1E, X2D, H2C, H2D, H2D Pro, H2S all see no behaviour change. P2S gets the only override today:probesize=1_000_000,analyzeduration=500_000— enough room for the slow keyframe without adding multi-second startup latency. Internal SSDP codes (e.g.N7→ P2S) resolve via an alias map so the camera path works during the early-connect window before the display name is settled. The two_RTSP_MAX_RECONNECTS/_RTSP_RECONNECT_DELAYmodule constants are gone in favour ofprofile.rtsp_reconnect_max/profile.rtsp_reconnect_delay; same defaults, but now overridable per model. Pattern is intentionally extensible — adding the next quirky model is a config entry in_PROFILES, not another global constant. Tests: 9 new intest_camera_profiles.pycover unknown model → default,None/empty → default, default preserves historical values, P2S has relaxed probe, P2S internal code (N7) resolves to P2S profile, lookup is case-insensitive, every other RTSP model still uses the default (so the next refactor regression is caught at unit-test time), profile is frozen (immutable). 58 existing camera-related tests still green; 5008 backend tests total green; ruff clean.
Changed
-
Inventory: spool ID surfaced in the edit modal and the AMS filament hover card (#1385, contributed by @chanakyan-arivumani in #1402, reported by @pgladel) — Reporter asked for the Spoolman / internal spool ID to be visible when editing a spool and when hovering the AMS-loaded filament tile, so the install can be cross-checked against the underlying spool row without opening Spoolman's UI separately. The data was already on the rendered components; only the rendering was missing.
SpoolFormModalheader now shows#<id>in muted monospace next to the "Edit Spool" title — but only in edit mode; copy and create paths don't surface an ID because no stable ID exists yet (a copy produces a new spool, and surfacing the source spool's ID there would mislead the user into thinking the new spool inherited it).FilamentHoverCard's assigned-spool block shows the same#<id>inline with the brand/material/colour line; the existing<p class="truncate">is wrapped in a flex container withmin-w-0on the parent andshrink-0on the new span so the truncation still kicks in on long names and the ID stays at full width. Inventory-mode parity holds without any branching — both internal and Spoolman spools carry anidwith the same shape so the modal renders the right ID regardless of which inventory backend is in use. Tests: one regression inFilamentHoverCard.test.tsx(asserts#42renders in the assigned-spool block) plus three added inSpoolFormModal.test.tsxas post-PR work — edit mode shows the ID, create mode shows none, copy mode shows none. The copy-mode test is the load-bearing case: a future refactor that drops theisEditing &&guard would silently start leaking the source spool's ID into the Copy header, and now fails the test instead. 51 affected frontend tests green; frontend build clean. -
Archives → Print Log: filename column expands to fit available width and wraps long names instead of clipping at 200 px (#1406, requested by @daFreeMan) — Reporter on a 27" monitor saw long filenames like
Simple_Print_Monitor_-_ST7789_1.54_display_case_truncated even though the table had plenty of unused horizontal space. The print-name<span>had a hardtruncate max-w-[200px]cap that ignored viewport width entirely. Replaced withbreak-words+ atitleattribute, dropping the explicit max-width so the column auto-sizes to content. On wide screens the full name shows on a single line; on narrow ones it wraps inside the cell instead of forcing horizontal scroll. Thetitlehover preserves the original tooltip affordance for the rare case where a really long name still gets truncated by viewport constraints. Frontend build clean; 23 ArchivesPage tests still pass.
Fixed
-
Library "Open in Slicer": broken when the display name lacked
.3mfor contained/ \ ? #(#1413, contributed by @benhalverson in #1416, reported by @ddingg) — Reporter on Windows 11 / Chrome saw Bambu Studio and OrcaSlicer reject the slicer URL from the 3D-preview modal's "Open in Slicer" button with a parse error, even though downloading the same URL with curl worked. The MakerWorld "Save and open" path and the "Recent imports" entry both worked fine — different code path. Root cause:GET /library/files/{file_id}/dl/{token}/{filename}uses the URL-tail filename purely as a hint for the slicer to detect the file format from the path; the backend itself readsfile.filenamefrom the DB (library.py:3806) and ignores the URL segment. WhenModelViewerModal.handleOpenInSlicerpassed the modaltitle(display name like"Mecha Mewtwo No AMS Multi Color Parted Statue"— no extension) verbatim throughencodeURIComponent, the resulting URL ended without.3mfand the slicer's client-side extension sniff refused to parse the response. The same path also let/ \ ? #through, which can surviveencodeURIComponent(/is unencoded by spec) and break the slicer's URL parser separately. Fix adds a smallbuildSlicerUrlFilename(filename)helper tofrontend/src/api/client.tsthat strips/ \ ? #(replacing them with_) and appends.3mfwhen missing;getLibrarySlicerDownloadUrlnow routes the filename through it beforeencodeURIComponent. The.3mfcheck is case-insensitive (safe.toLowerCase().endsWith('.3mf')) somodel.3MFis handled correctly — a subtle improvement over the equivalent inline logic that's still present on the archive-side helpersgetArchiveForSlicerandgetArchiveSlicerDownloadUrl(call-site consolidation is a separate follow-up). Safe becauseModelViewerModal.tsx:264already gates the button tofileType === '3mf'library files, so unconditional.3mfappend never produces nonsense likemodel.gcode.3mf. Two new tests infrontend/src/__tests__/api/client.test.tscover both branches (display name without extension →.3mfappended; display name with/ ? #→ replaced with_). 21 client tests green; frontend build clean. -
Add Spool modal: hex colour field can be typed into character-by-character again (#1407, reported by @anthonyma94) — Pre-fix, after typing the first hex character the input's value snapped to e.g. "A00000" (the #1055 fix aggressively padded to 8 chars on every keystroke), the cursor jumped to the end, and the next keystroke landed at position 7 — which the original 7-char-truncation branch then dropped. Net effect: only the first character was ever typed; the rest stayed as zeros unless the user pasted a full hex code. Fix splits "what the user is typing" from "what gets sent to the backend": the input now has its own draft state holding 0–6 chars freely, and
updateField('rgba', ...)only fires once the draft reaches a complete 6-char RGB (commits as<6chars>FF). On blur, a partial 1–5 char draft is right-padded with0and committed so the form state always carries a valid 8-char rgba — preserves the #1055 invariant that the backend never sees a malformed value, without re-introducing the truncate-on-keystroke trap. AuseEffectkeeps the draft in sync when an external action (the colour picker, a swatch click, edit-mode load) changes the canonical hex. Paste of 7-/8-char strings truncates to the leading RGB triplet: Bambu filaments are opaque and the UI never exposed an alpha affordance, so dropping the (undocumented) "paste with alpha" case is fine. The existingColorSectionHexInput.test.tsxwas rewritten to match the new contract — 8 tests covering both new behaviours (draft reflects each keystroke, no commit while partial, commits on length 6, blur-padding for partials, no commit when cleared then blurred) and the kept #1055 invariants (committed rgba is always 8 hex chars, 7-/8-char paste truncates, non-hex chars stripped). 51 spool-form frontend tests green; frontend build clean. -
Virtual Printer queue: timelapse / bed-leveling / flow-cali / vibration-cali / layer-inspect now inherit the slicer's choice instead of always falling back to global defaults (#1403, reported by @pwostran) — Reporter sliced in OrcaSlicer with timelapse enabled, sent to a VP queue, started the job from the queue and got no timelapse video. The dispatch chain itself was correct (queue item → scheduler → MQTT command honours
timelapse); the gap was at queue-add time: the VP's_add_to_print_queuereaddefault_timelapsefrom settings (introduced in #1235 to stop column defaults from winning), but ignored the slicer's project_file MQTT command entirely. The slicer's choice — which Bambu Handy / Bambu Studio / Orca all surface in their "Print options" dialog and ship in the MQTT payload astimelapse: true|1— was being thrown away. So a user withdefault_timelapse=false(the new-install value) would have to either flip the global setting or manually edit every queue item, even though their slicer's UI was clearly saying "record timelapse for this job". Fix:on_print_commandin the VP manager now stashes the slicer's project_file dict keyed by filename, and_add_to_print_queuewaits up to 2 s for that capture before reading the settings fallback. Each option flows through per-field — slicer value wins if present, else the existing settings default (so users who explicitly setdefault_timelapse=truein their VP workflow card still get that on slicers that don't send a print command). MQTT field naming is preserved exactly:bed_leveling(single L) on the wire stays mapped tobed_levelling(double L) on the Bambuddy column. Integer 0/1 from H-family slicers and bool true/false from P1/X1 slicers both coerce correctly viabool(). Wait is skipped when there's no MQTT server attached to the VP instance (covers unit tests calling_add_to_print_queuedirectly so they don't pay the 2 s tax) and capture is consumed on use so the dict stays bounded across many prints. Two new regressions intest_virtual_printer.py::TestPrintQueueMode:test_add_to_print_queue_inherits_slicer_print_options(slicer=True overrides settings=False across all 5 fields; capture is consumed) andtest_add_to_print_queue_coerces_slicer_integer_zero_one(H-family integer payload is coerced). The existing#1235test (test_add_to_print_queue_uses_workflow_defaults_from_settings) still passes because the no-MQTT-attached gate skips the wait, so the settings fallback path is preserved when no slicer capture exists. Plus two side-bugs surfaced while investigating Martin's "modal not respected" hypothesis — the support-package evidence cleared the reprint modal (46print_schedulerand 33background_dispatchevents withtimelapse: trueshipped to real P1S printers, end-to-end working), but the same dig turned up two latent issues worth fixing in the same pass: (a)POST /webhook/printer/{id}/startwas broken on four axes —await printer_manager.start_print(...)against adef(notasync def) function,queue_item.archive_id(int) passed as thefilenamearg,printer_manager.get_status(...).get(...)against aPrinterStatedataclass (not a dict), and every print option discarded (timelapse, bed_levelling, AMS mapping). The route would 500 before ever reaching the printer. Rewritten to mirrorPOST /print-queue/{item_id}/start: just clearmanual_start=Falseon the next pending queue item and let the scheduler dispatch it with the queue's stored options intact. Three new regressions intest_webhook_start_print.py(clearsmanual_start, preserves stored print options, 404 when no pending items / unknown printer). (b)vibration_calidefault drift inbackground_dispatch.py—ReprintRequest.vibration_caliandFilePrintRequest.vibration_caliboth default toTrue(matches Bambu Studio behaviour for X1/P1 series), but the two_process_jobcall sites readjob.options.get("vibration_cali", False). Cosmetic today because the frontend always sends the field, but a latent landmine for any future caller that bypasses the schema (e.g. an internal dispatcher seeding options programmatically). Both call sites flipped toTrue; new contract testtest_dispatch_option_defaults_align_with_request_schema_defaultsintrospects the source to lock the alignment for all six print-option fields so this drift can't recur. 116 VP unit tests green; 4999 backend tests green; ruff clean. -
Inventory: "Reset usage to 0" no longer inflates remaining weight back to label_weight (#1390 follow-up, reported by @IndividualGhost1905) — Reporter reset a 544 g spool's consumed counter and watched its displayed remaining jump to 1000 g — exactly the opposite of what the dialog promised ("Spools and remaining weights are not changed"). Root cause was an architectural conflation in the internal inventory model: a single
weight_usedcolumn was doing two jobs, the resettable "consumed since tracking started" stat AND the basis for the displayed remaining (label_weight - weight_used). Zeroing it correctly cleared the stat but unavoidably reset remaining to full. Spoolman has separateused_weightandremaining_weightfields, so its API call was correct, but Bambuddy's frontend was also computing remaining aslabel_weight - weight_usedfor Spoolman spools (ignoring Spoolman's realremaining_weightfield), so the same visual bug bit in Spoolman mode too. Internal mode fix: newweight_used_baselinecolumn (Float, default 0) on thespooltable; the "Total Consumed" display is nowweight_used - weight_used_baselineclamped to ≥0; the reset endpoints stampbaseline = weight_usedand leaveweight_useduntouched, so remaining (=label_weight - weight_used) is preserved. Subsequent prints continue to growweight_usedand the resettable counter naturally tracks the post-reset delta. Spoolman parity fix:_map_spoolman_spoolnow reads Spoolman'sremaining_weightfield and returns a syntheticweight_used = label_weight - remaining_weightso the frontend's remaining calc matches Spoolman's real stored value;weight_used_baselineis computed assynthetic_weight_used - real_used_weightsoweight_used - baselineequals Spoolman'sused_weight(the resettable counter). After a Spoolman reset (real used_weight=0, real remaining_weight=544) the user sees consumed=0 and remaining=544 — identical to internal mode. Also fixed a related Spoolman bug: editing a spool's metadata after a reset would PATCH Spoolman withremaining_weight = label - used_weight = 1000, overwriting the real 544 g;update_spoolnow derives the defaultweight_usedfrom Spoolman'sremaining_weightinstead ofused_weightso non-weight edits preserve the existing physical state. FrontendtotalConsumedaggregate inInventoryPageand the three "consumed" displays inForecastPanel(delta-rate, per-SKU totalUsedG, per-spool consumed cell) all switched toMath.max(0, weight_used - (weight_used_baseline ?? 0)). The?? 0fallback keeps pre-migration installs rendering correctly untilinit_db()runs the idempotentALTER TABLE spool ADD COLUMN weight_used_baseline REAL DEFAULT 0(works on both SQLite and Postgres). Tests:test_spool_reset_usage.pyrewritten — old asserts that the endpoint zeroedweight_usednow assert it stamps baseline = weight_used and leaves weight_used alone, plus a newtest_reset_then_print_advances_only_the_countertest that simulates a 50 g print after a reset and confirmsconsumed=50, remaining=494(i.e. remaining keeps decrementing across the reset).test_spoolman_inventory_helpers.pygets two new tests on the mapper covering pre-reset and post-reset Spoolman shapes.test_spoolman_inventory_api.py::test_reset_spool_usageupdated to assert the new InventorySpool contract (consumed=0, remaining=750, baseline absorbs the reset). 4993 backend tests green; ruff clean; frontend build clean. Per the inventory-parity rule saved last session: both modes now ship the same UX, both call sites verified end-to-end before declaring done. -
Adding a printer with a wrong access code (or unreachable IP) no longer creates an empty card — Several support reports traced back to a single root cause: the user mistyped their access code in the Add Printer dialog,
POST /printers/happily persisted the row, the subsequentprinter_manager.connect_printer()call was fire-and-forget so the failure was invisible, and the dashboard ended up showing a printer card that could never display state. The create route now runsprinter_manager.test_connection()(the same MQTT probe the standalone Test Connection button has always used) BEFORE inserting the row, and refuses with HTTP 400 if the probe fails. The Printer row is never written on failure. Structured error response: backend returns{detail: {code: "printer_connection_failed", message: "..."}}rather than a plain English string — the newApiError.codefield on the frontend lets the toast layer pick a localizedprinters.toast.connectionFailedNotAddedkey instead of surfacing the English fallback. Existing tests kept green via an autouse_mock_printer_test_connectionfixture intest_printers_api.pythat defaults the probe to success; a newtest_create_printer_rejects_when_mqtt_probe_failsasserts the failure path returns 400, surfaces the stable code, AND verifies the row was not persisted (the critical part — earlier versions of the regression would have passed even if we'd left the row behind). 8 new i18n translations forprinters.toast.connectionFailedNotAddedacross all 8 locales; parity holds at 4831 leaves. 28 printer-route tests green.
Changed
- GitHub backup: save-failure messages render inline on the card instead of as a toast — The new "repository is not private" rejection message is ~250 chars listing every credential the backup carries, which clips badly in a toast. Both the initial-setup save and the debounced autosave now stash the backend's error message into a new
saveErrorstate and render it as a red inline banner above the test-result block, withwhitespace-pre-wrapso the full message stays readable. The banner clears on a successful save, on the next save attempt, and as soon as the user starts editing the URL / token / provider (the three fields whose changes invalidate the privacy check) — so it doesn't linger after the user has already addressed the cause. Short success toasts ("Settings saved", "Token updated", "Backup enabled") are unchanged. Manual dismiss button included for users who want to clear it without retrying.
Security
- GitHub backup refuses to save against a non-private repository — While auditing real-world Bambuddy backup repos on GitHub I found several that were left public by their owners. That's a serious data leak: the settings backup only filtered
bambu_cloud_tokenandauth_secret_key, somqtt_username,mqtt_password,ha_token,prometheus_token,bambu_cloud_email,external_url, and the printer access codes (via K-profiles, which carry the serial number) were going to whatever visibility the user picked when they created the repo. Fix is a hard guard at every save and re-checked on every push:POST /github-backup/configandPATCH /github-backup/config(when the URL, token, or provider changes) run a connection test internally and return HTTP 400 unlessis_privatecomes back True. Same check fires insiderun_backup()before every scheduled or manual push, so a repository that was private at config time but later flipped to public in the provider's UI gets a clear "Backup aborted: the target repository is no longer private" failure entry instead of leaking the next backup. Implementation: each provider'stest_connection(GitHubBackend,ForgejoBackendoverride,GitLabBackendoverride;GiteaBackendinherits unchanged) now returnsis_private: bool | None—Truefor confirmed private,Falsefor public (or GitLab'sinternal),Nonefor "couldn't determine" (older self-hosted APIs, non-2xx responses). The route helper_enforce_private_reporejects anything that isn'tTrue, with separate error messages for the public case ("Make the repository private...") vs the unknown-visibility case ("...could not confirm..."). Frontend test-connection UI now renders the visibility result inline — green check + "Repository is private — safe to back up to" when confirmed, red banner with the full list of credentials at risk + "Saving is blocked until..." when public, yellow banner + "could not determine" when null. Three new i18n keys (repoIsPrivate,repoIsPublicWarning,repoVisibilityUnknown) translated across all 8 locales; parity holds at 4830 leaves. Wikidocs/features/backup.mdgains a top-level!!! danger "Private repositories only"block listing what's at stake and what to do if the user already has a public backup repo, plus every per-provider setup step is updated from "(can be private)" to "(must be private)". Tests: 5 new intest_github_backup_api.py::TestGitHubBackupPrivateRepoGuard— create rejects public (400 + "not private" in detail), create rejects unknown visibility (400 + "could not confirm"), create rejects failed test_connection (400 + propagates the underlying message), PATCH that changes the URL re-runs the check and rejects on public, PATCH that touches an unrelated field (e.g.schedule_enabled) does NOT calltest_connection(proven via a mock that raises if called — without the field-change gate, every benign toggle would trigger a live API call). The existing 15 tests now use an autouse fixture that mockstest_connectionto return private-success so they don't try to reach github.com. 4905 backend tests green.
Fixed
-
Spoolman edit-spool: editing a spool no longer mints duplicate filaments in the Spoolman catalogue (#1357 follow-up, reported by @pgladel) — After the initial #1357 close, the reporter showed that BB was still spawning new Spoolman filament rows on every subsequent edit. The previous fix taught
find_or_create_filamentto bridge the AMS-sync name shape ("Glow") with the user-edit shape ("PLA Glow"), but only on the find path — the moment the user changed any field that fed the match key (subtype/material/brand/color_hex) the lookup missed and a brand-new filament was created, the spool was re-linked to it, and the previous filament was orphaned. Repeating the loop produced the spread the reporter screenshotted (IDs 126/127/128/129 all "Amazon Basics / PLA Glow / PLA", slight color variants). Root fix is a behaviour change inPATCH /spoolman/inventory/spools/{id}: before callingfind_or_create_filament, the route now computes whether the desired metadata still matches the current linked filament and, if so, skips the lookup entirely (a no-op metadata edit — justnoteorweight_used— never touches the filament catalogue). When metadata IS changing it consults a newSpoolmanClient.is_filament_shared(filament_id, exclude_spool_id)helper: if the current filament is a singleton (only this spool points at it, archived spools included so a sibling-archive doesn't fake singleton-ness), the route PATCHes that filament in place viapatch_filament—name,material,color_hex,weight, plus avendor_idresolved viafind_or_create_vendorwhen the brand changed. Only when the filament is genuinely shared with another spool does the route fall back to the legacyfind_or_create_filamentpath, because PATCHing a shared filament would silently rewrite every sibling spool's metadata. Net effect mirrors internal-inventory behaviour (feedback_inventory_modes_parity saved this session): editing a spool updates the thing the spool already points at, instead of proliferating new entities. Three new tests intest_spoolman_inventory_api.py::TestSpoolmanInventoryCRUDcover the new contract: a no-op metadata edit (onlynote/weight_used) does NOT callfind_or_create_filamentORpatch_filament; a subtype change against a singleton filament callspatch_filament(7, {...name: "PLA Matte"})and NOTfind_or_create_filament; the same change withis_filament_sharedmocked to True falls back tofind_or_create_filamentand does NOT callpatch_filament. 162 spoolman-inventory tests + 192 broader spoolman tests green; ruff clean. -
Inventory: "Print labels…" now works in Spoolman mode — Both endpoints already exist (
POST /inventory/labelsfor the built-in table,POST /spoolman/labelsfor Spoolman), and theLabelTemplatePickerModalcorrectly branches on aspoolmanModeprop. But the modal was instantiated inInventoryPage.tsxwithspoolmanMode={false}hard-coded, with a stale comment from the original PR claiming "Spoolman path hands users an iframe straight to Spoolman so the per-spool button never shows in that context". That assumption stopped being true when the unified inventory UI shipped — the per-spool button DOES show in Spoolman mode now, but every click resolved to/inventory/labelswith Spoolman spool IDs and returned404 Spool(s) not found. Fix passes the actualspoolmanModevalue through to the modal (one-line change, plus removing the stale comment block). The existingLabelTemplatePickerModal.test.tsxalready covers both branches at the component level — the gap was that no test exercised the InventoryPage wiring. This is another instance of the parity rule from [#1390 follow-up]: inventory features must ship the same UX in both modes; per the new feedback memory, any future inventory change gets a mental checklist of both routes + both client methods + both UI gates before being considered shipped.
Added
- Inventory: "Reset usage to 0" also works in Spoolman mode (#1390 follow-up) — The first cut of this action only wired the built-in inventory path, so Spoolman users saw the eraser icon disappear when they switched modes. Now the same two endpoints exist on the Spoolman inventory router:
POST /spoolman/inventory/spools/{spool_id}/reset-usagePATCHes Spoolman's/spool/{id}withused_weight: 0for a single spool,POST /spoolman/inventory/spools/reset-usage-bulkdoes the same per ID across an explicit list and returns{reset: N}(individual Spoolman failures are logged and counted out, the batch keeps going). Areset_spool_usage(spool_id)helper onSpoolmanClientis the actual HTTP call. The mutations inInventoryPage.tsxalready had the right shape — they now switch onspoolmanModeto pickapi.resetSpoolmanInventorySpoolUsage/api.bulkResetSpoolmanInventorySpoolUsagevs the internal-inventory client methods, and the threespoolmanMode ? undefined : ...gates that hid the eraser buttons in Spoolman mode are gone. Three new tests intest_spoolman_inventory_api.pylock the Spoolman path (per-spool, bulk, and the typo-wipe guard on empty list). The wiki page now says "Spoolman users get the same actions" instead of the original "Spoolman-mode users don't see either button" note. 4900 backend tests green. - Inventory: "Reset usage to 0" per spool and across all active spools (#1390 follow-up, requested by @IndividualGhost1905) — Each spool's
weight_usedcounter accumulates over its lifetime and feeds the "Total Consumed (Since tracking started)" stat on the Inventory page. There was no way to clear it without nuking the spool or manually editing the field — and manually settingweight_used=0via PATCH /spools/{id} auto-locks the spool (weight_locked=trueis auto-set wheneverweight_usedis sent explicitly, so AMS auto-sync stops touching the spool), which is the wrong behaviour for "clean-slate my Total Consumed stat so future prints track from zero". Two dedicated endpoints inbackend/app/api/routes/inventory.pyzero the counter without touching the lock flag:POST /inventory/spools/{spool_id}/reset-usage(single spool) returns the updatedSpoolResponse;POST /inventory/spools/reset-usage-bulk({spool_ids: [int, ...]}) returns{reset: N}. The bulk endpoint rejects empty / missingspool_ids(HTTP 400) — no wildcard / "reset-all" shortcut, since a typo there would wipe the entire inventory's tracking; the caller must explicitly pass the list. Both leaveweight_lockedalone: if the user had locked the spool, the lock stays; if it was unlocked, it stays unlocked and the next AMS sync picks up from zero. Frontend adds two affordances: a small eraser icon button on the "Total Consumed" stat card (visible only when there's actually usage to reset AND we're not in Spoolman mode) that opens a confirm modal explaining what the reset clears and that the spools / remaining weights are not changed, and an eraser icon in each table row's action column (visible only on active spools withweight_used > 0, hidden in Spoolman mode since Spoolman manages its own usage accounting). Both routes share the sameConfirmModalinfrastructure as delete/archive —confirmActionstate now covers'delete' | 'archive' | 'reset-usage' | 'reset-all-usage'. i18n: 10 new keys (resetUsage,resetUsageTooltip,resetUsageConfirm,resetAllUsage,resetAllUsageTooltip,resetAllUsageConfirm,usageReset,allUsageReset,resetUsageFailed, plusresetUsagereused as confirm button label) translated across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). Parity check holds at 4827 leaves per locale. Tests: 8 new regressions intest_spool_reset_usage.pycover per-spool reset zeroesweight_used, per-spool reset does NOT auto-lock, per-spool reset preserves an existing lock, 404 for missing spool, bulk reset zeroes only listed spools (untouched spools keep their usage — the typo-wipe guard), bulk reset rejects empty list (400), bulk reset rejects missingspool_idsfield (400), bulk reset preservesweight_lockedacross mixed locked/unlocked targets. 4897 backend + 1901 frontend tests green.
Changed
- Settings → Filament: "Spool Catalog" now shows the same UI in Spoolman mode as in internal-inventory mode — Previously, switching to Spoolman mode hijacked the Spool Catalog card and replaced it with a Spoolman filament list (Vendor — Name / Material / Weight / Spool Weight) with inline edit for name + spool_weight. Two separate concepts had been merged into one card: a Bambuddy-local spool tare catalog (the actual purpose of the card — name + weight definitions used to compute spool tare) vs a filament editor for Spoolman's
Filamententity. The filament-editor view replaced the spool tare table entirely in Spoolman mode, with no way to see or manage the spool catalog. Now the card always renders the local Spool Catalog (Add / Edit / Delete / Export / Import / Reset / bulk-delete) regardless of inventory mode. The Spoolman-filament inline editor is removed — Spoolman users edit filament name / spool_weight in Spoolman's own UI. Side effect of the rewrite: the noisyGET /api/v1/spoolman/inventory/filaments → 400 Bad Requestthat fired on the Filament settings page even when Spoolman is disabled is gone, because the component no longer issues the probe at all. Files affected:frontend/src/components/SpoolCatalogSettings.tsx(rewrite, ~750 → ~445 lines),frontend/src/components/SpoolWeightUpdateModal.tsx(deleted — only used by the removed editor), test file rewritten to match the simplified component. No backend changes —PATCH /spoolman/inventory/filaments/{id}route still exists for API consumers, just no longer wired to a UI.
Fixed
- Stats page widgets now match Quick Stats — every panel reads per-event data (#1390 follow-up, reported by @IndividualGhost1905) — After #1378 moved Quick Stats and the run aggregates to
print_log_entries, six widgets (Filament Used, Filament Cost, Filament Trends, Printer Stats By Weight / Time, By Material, Color Distribution) plus Failure Analysis still iterated the archive list. Two divergences fell out of that split. Reprints: each reprint of an archive adds a newprint_log_entriesrow but theprint_archivesrow gets overwritten in place, so event-based widgets counted N reprints while archive-based widgets counted 1. Hard-deleted archives: the foreign key isON DELETE SET NULL, so the event survives as an orphan (archive_id=NULL) — Quick Stats kept counting it, archive-iterating widgets couldn't see it. The reporter's test server (14 archives / 52 events / 29 orphans confirmed by the diagnostic query) made the split very visible. Fix swaps the data source in two places: (1)GET /archives/slim(the only frontend caller is StatsPage, so every widget that consumes thearchivesquery gets the per-event data in one step) now reads fromPrintLogEntry, LEFT JOINsPrintArchivefor the slicedprint_time_secondsestimate (null for orphans, and downstream widgets already fall back toactual_time_seconds/duration_seconds), usesPrintLogEntry.duration_secondsas the authoritative measured-time field when present (the original computed-from-started/completed_at path is kept as the fallback so legacy event rows from pre-#1378 still surface time), and returnsquantity=1per event since per-event semantics make the archive-level quantity multiplier meaningless (verified no StatsPage widget actually readsquantity—grep -n "\\.quantity" frontend/src/pages/StatsPage.tsxreturns nothing); (2)FailureAnalysisServiceswitched fromPrintArchivetoPrintLogEntryfor every aggregation (totals, by reason, by filament, by printer, by hour, recent failures, weekly trend) —project_idfiltering still resolves through the archive table (events don't carry a direct project link) but counts the matching events, not the archives. The conftestarchive_factoryalready synthesizes a matchingPrintLogEntryper archive (added when #1378 landed), so existing tests stay green; one small tweak there now syncs the synthesized event'screated_atwith the archive's so date-range filtered tests don't lose the event toserver_default=func.now(). Three new regressions intest_archives_api.py:test_slim_counts_reprints_as_separate_rows(three reprints → three slim rows → 3× filament summed correctly),test_slim_includes_orphan_events(archive deleted, event survives, slim still returns it withprint_time_seconds=null),test_failure_analysis_counts_reprints_and_orphans(a reprint of a failed archive + an orphan failed event both contribute tofailed_printsandfailures_by_reason). One existing assertion updated — thetest_slim_returns_only_expected_fieldstest was assertingquantity == 2from anarchive_factory(..., quantity=2)call, which no longer rounds-trips through the per-event endpoint; updated toquantity == 1with a comment pointing at the semantic shift. 4889 backend tests green, 31 StatsPage frontend tests green, ruff clean. - FTP upload no longer silently treats 426 "Failure reading network stream" as success (#1401, second root cause reported by @iitazz) — Looking at the support bundle from @iitazz showed every FTP upload to their P2S (firmware 01.02.00.00) ending the same way: data channel sendall completes in ~200 ms at an impossibly high "speed" (7+ MB/s for files the printer can only actually receive at ~1–2 MB/s), then voidresp returns
426 Failure reading network stream. (error_temp)from the printer, and Bambuddy proceeds —WARNING FTP STOR confirmation not received for X (proceeding): 426 ...followed immediately byINFO FTP upload complete. The print command then gets dispatched, the printer tries to parse what's actually a partial 3MF (the reporter's downloaded-from-printer 3MF was 458752 bytes — exactly7 × 65536, our FTP chunk size — for a 668025-byte source), and surfaces the "unable to parse 3mf file" error the reporter sees. Two stacked failures: a P2S firmware / TLS-data-channel quirk that severs the FTP data stream mid-transfer (separate investigation; #1401 doesn't fix that), AND the voidresp handler inbackend/app/services/bambu_ftp.pyswallowing the resulting 426 because the original comment assumed "the data was fully sent so the file is likely on the SD card" — true for socket-level timeouts where we just didn't HEAR the 226 in time (H2D needs 30+ s tolerance and we want to keep that), false for426where the printer is explicitly telling us the data stream itself was cut. Fix splits the broadexcept Exceptioninto two branches:except ftplib.Error(coverserror_reply,error_temp,error_perm,error_proto— the server responded with a failure on the control channel) logs at ERROR and re-raises, so the outerexcept (OSError, ftplib.Error)returns False and the dispatcher sees a real upload failure instead of green-lighting a print of a truncated file;except Exceptionkeeps the existing proceed-with-warning behaviour for socket timeouts so the H2D 30-second voidresp tolerance survives. Same split applied toupload_bytes()since it had the sameexcept Exception: passshape. The reporter will still hit the underlying 426 (we haven't fixed the P2S transport problem yet — that's separate), but they'll now see an upload failure surfaced honestly rather than a confusing parse error 30 seconds into the print attempt. Tests: two new regressions inTestUploadpatch_ftp.voidrespto raiseftplib.error_temp("426 ...")and assert bothupload_file()andupload_bytes()return False. 18 upload-related tests green. The earlier-this-section validation fix is unrelated and stays — it still catches genuinely raw.gcodefiles at the upload step. - Upload validation rejects unprintable 3MF / raw-gcode files at the upload step instead of letting them fail at the printer (#1401, reported by @iitazz) — Reporter sliced in OrcaSlicer, uploaded the result to Bambuddy, clicked Print, and the printer rejected with "Printing stopped because the printer was unable to parse the 3mf file" — every time, for multiple files, on both library uploads and SD-card-browsed files. Trace through the support bundle showed: (a) the stored library file ended in
.gcode(not.gcode.3mf), and (b)background_dispatch.pyconstructs the FTP destination filename by appending.3mfwhen the source doesn't already end in.gcode.3mf/.3mf— so raw gcode gets shipped to the printer namedwhatever.gcode.3mfand the firmware's 3MF parser chokes on the missing zip header. The same shape also manifests asFailed to parse plates from archive ... File is not a zip filewarnings on Bambuddy's side. Whether the user manually re-extensioned a file or their slicer saved as.gcodeinstead of.gcode.3mf, the right place to catch this is the upload, not the printer 30 seconds later. Newvalidate_print_file_upload()helper inbackend/app/api/routes/library.pyruns two checks: (1) reject any filename ending in.gcode(but not.gcode.3mf) with a clear message — "Raw .gcode files can't be printed on Bambu printers in network mode — they need a .gcode.3mf zip container (gcode plus metadata). Re-export from your slicer and make sure the file ends in '.gcode.3mf', not just '.gcode'. If your OS hides extensions, double-check the file with the extension visible." (2) For any filename ending in.3mf(incl. the compound.gcode.3mf), verify the file body starts withPK\x03\x04(ZIP magic bytes); reject otherwise with a message pointing at the slicer's "Export Plate Sliced File" action. Suffix-based check rather thanos.path.splitextbecause compound extensions like.gcode.3mfshow up as just.3mfafter splitext — both must trigger the same validation. Applied to every relevant upload route:POST /library/files(covers File Manager upload AND the printer-card drag-drop, which routes through the same endpoint),POST /archives/upload(single archive),POST /archives/upload-bulk(rejects bad files per-row instead of aborting the batch — one bad file in a 10-file drag-drop doesn't lose the other nine),POST /archives/{archive_id}/source(per-archive source 3MF),POST /archives/upload-source(slicer-post-processing match-by-name). Validation runs AFTER_resolve_upload_destinationso folder-permission rejections (403 readonly, 400 missing-path, 409 collision) still take precedence — preserves existing error ordering. STL / image / other non-print uploads bypass the validator entirely; Bambuddy is also a library, not just a print dispatcher. Frontend visibility fix inFileUploadModal.tsx(same component used by File Manager + Printers page + Archives): the modal auto-closed aftersetIsUploading(false)regardless of per-file results, so a 400 rejection from the new validator was technically captured but never shown — the modal vanished too quickly. Now (a) errors render inline as red text under the file row instead of as a hover-onlytitletooltip, and (b) the modal stays open if any file ended with status='error', so the user can read the backend's actual remediation message before clicking Close. The bulk archiveUploadModal.tsxwas already showing inline errors and not auto-closing — that one didn't need the fix. Tests: 7 new integration tests inTestPrintFileUploadValidationcover: raw.gcoderejection at the library route (asserts the error message names the remedy), non-zip.3mfrejection, non-zip.gcode.3mfrejection (compound-extension code path), happy-path valid.gcode.3mfaccepted, STL / non-print extensions still bypass,POST /archives/uploadnon-zip rejection,POST /archives/upload-bulkper-file error collection with mixed good/bad files in one request. Plus one fixture update intest_external_folders_api.py—test_upload_persists_correct_db_shapewas uploadingmodel.3mfwith placeholder bytesb"x"to exercise the DB-shape path; updated to use a minimal real zip so the new validator doesn't block the unrelated test. 4968 backend tests green, 41 FileUploadModal frontend tests green, ruff + frontend build clean.
Added
-
Inventory: Storage Location filter chip (#1400, reported by @pgladel) — Reporter manages a lot of physical filament storage locations and wanted a quick way to narrow the inventory list to "what's in shelf A" / "what's in drawer 1" without typing a search query each time. Inventory page grows a new filter chip alongside the existing Material / Brand / Category / Spool Name dropdowns. Distinct storage-location values are pulled from the spool list and rendered as options; selecting one filters the table to spools assigned to that location. An additional No location set entry appears when at least one spool has an empty
storage_location, so users can find unfiled spools the same waycategoryNoneworks for unfiled categories. The chip self-hides when no spool has a storage location set (avoids noise on fresh installs). Pattern is identical to the existing Category chip from #729 — clear-all-filters andhasActiveFiltersboth include the new state. Whitespace normalisation: distinct-value extraction and filter comparison both.trim()the field so a spool whose location was saved as"Shelf A "doesn't render as a separate dropdown option from"Shelf A". i18n: reuses the existinginventory.storageLocationlabel (already shipped for the spool-edit field — no duplication); adds a newinventory.storageLocationNonekey, translated to all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). The "Extended Solution" from the issue (dashboard widget showing locations) is not in this change — open to revisiting if there's appetite. Parity check holds at 4818 leaves per locale. 24 InventoryPage tests in the existing suite still pass. -
Smart plugs: auto-off after AMS drying completes (#1349, reported by @Kyobinoyo) — Reporter asked for the equivalent of the existing print-finish auto-off, but triggered when an AMS drying cycle ends — so the smart plug that powers the printer + AMS combo cuts power once humidity has been driven out, without the user babysitting it. Shipped as a simple per-plug pair of fields that mirrors the existing print-finish auto-off shape. Per-AMS plug routing (separate plug for the AMS only, per-AMS targeting on dual-AMS printers) was scoped out for now — Bambuddy's plug model is plug→printer, not plug→AMS, so the trigger fires whenever any AMS attached to the linked printer finishes a dry cycle. Two new SmartPlug columns with a same-migration block in
database.py(SQLite usesBOOLEAN DEFAULT 0/INTEGER DEFAULT 10; Postgres branches toDEFAULT false/IF NOT EXISTS):auto_off_after_drying BOOLEAN(defaults False so nobody opts in by accident);off_delay_after_drying_minutes INTEGER(defaults 10 — separate from the print-finish delay because the AMS chamber is hot post-cycle and users often want longer cooldown than the print-finish default of 5). Trigger is observed at the MQTT layer, not the scheduler —BambuMQTTClientnow keeps a per-AMS_previous_dry_times: dict[int, int]and, every time_handle_ams_datafinalises the merged AMS list, walks each unit looking for thedry_time > 0 → 0falling edge. When it fires, the newon_drying_complete(ams_id)callback runs, plumbed throughPrinterManager.set_drying_complete_callbackexactly the wayon_print_start/on_print_completealready are. The seed-from-zero false positive (first MQTT push reportsdry_time=0and the previous would otherwise read as 0→0) is guarded by the explicitprevious > 0check, and the per-AMS state means dual-AMS printers can finish drying on AMS 0 and AMS 1 independently without the second one missing the edge. Observing the falling edge at the MQTT layer (rather than inprint_scheduler._sync_drying_state) is deliberate: the scheduler's_drying_in_progressdict only tracks auto-drying initiated by the scheduler itself, so manually-triggered drying from the printer card would not fire there. The new path catches queue-triggered, ambient, AND manual drying identically because it observes firmware-reported state, not our own intent. Manager hook inSmartPlugManager.on_drying_complete(printer_id, db)mirrorson_print_completebut reads the drying-specific toggle, calls_schedule_delayed_offwithoff_delay_after_drying_minutes(always time-based — temperature-cooldown is meaningful for the printer hotend, not the AMS chamber, and Bambuddy doesn't track AMS chamber temperature). The HA-script guard from the print-finish path is preserved (scripts can be triggered but not turned off, so they're skipped). Frontend adds a single toggle + delay input on the Smart Plug card next to the existing "Auto Off" section: "Auto Off After Drying" and "Drying delay (minutes)". No changes to the Add Smart Plug modal beyond what the new fields require. Backend tests intest_smart_plug_manager.pycover the new shape: drying auto-off schedules with the correct per-plug delay; the toggle being off is a no-op even whenauto_off(print-finish) is on; the masterenabledflag still gates; HA script entities are skipped; printer with no linked plugs is a silent no-op.test_bambu_mqtt.pygets a newTestDryingCompleteCallbackclass covering the falling-edge firing once, the seed-from-zero non-fire guard, repeated zero-pushes after the edge not refiring, per-AMS independent tracking on dual-AMS units, and the "new cycle after completion refires" case (covers the user starting a second dry from the printer card). 4961 backend tests green; SQLite + Postgres 16 migration verified idempotent. i18n: 3 new keys (autoOffAfterDrying,autoOffAfterDryingDescription,delayAfterDryingMinutes) translated across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). Parity check holds at 4817 leaves per locale.
Changed
- Bulk and scheduled archive purge now honour the soft / hard delete choice that single-archive delete already exposes (#1390 follow-up) — Reporter IndividualGhost1905 followed up after the #1378 / #1343 backfill fix landed and pointed out the next inconsistency: the per-archive delete dialog has had a "Also remove from Quick Stats" checkbox since #1343, but the bulk "Purge Old" button and the scheduled daily auto-purge sweeper both ignored that choice and hard-deleted unconditionally. The "Purge Old" path called
archive_purge_service.purge_older_thanwhich routed throughArchiveService.delete_archivedirectly — dropped the archive row, the linked PrintLogEntry rows gotON DELETE SET NULLso they survived witharchive_id=NULL, Quick Stats kept the filament / cost / energy contribution from the orphaned log rows but the archive-list-iterating widgets (Filament Trends / Printer Stats / By Material / Color Distribution) lost the contribution and Time Accuracy lost the join target. Visibly inconsistent, and "automatically deleted from statistics without any warning" was a fair characterisation of the half that did drop. Fix is to thread the samepurge_statsparameter through every surface, defaulting to soft-delete (matches the single-archive default — files off disk, archive row hidden viadeleted_at, Quick Stats fully preserved, all archive-list widgets keep showing the row). Three surfaces affected: (1)POST /archives/purgeacceptspurge_statsin the body, defaults False (soft); the response now echoes which mode ran. (2)GET /archives/purge/previewaccepts the same flag as a query param so the count matches what a real purge would touch — soft mode excludes already-soft-deleted rows, hard mode counts them as eligible-for-promotion. (3) The auto-purgearchive_auto_purge_statssetting (default False) controls whether the daily sweeper runs in soft or hard mode; the existing_maybe_run_auto_purgereads it on every tick.ArchivePurgeRequest/ArchivePurgeSettingsschemas extended,archive_purge_service.purge_older_thanandpreview_purgetakepurge_stats=Falsekwarg, the existing single-row delete tests pass unchanged. Frontend: "Purge old archives" modal grew a checkbox below the preview ("Also remove from statistics" with a hint explaining the difference), and the Settings → Archives auto-purge card grew the matching toggle (disabled when auto-purge itself is off). Copy in the modal rewritten across all 8 locales to reflect that the default no longer "permanently removes from the database" but instead hides + removes files while keeping Quick Stats intact. Behaviour change for existing auto-purge users: the sweeper used to hard-delete by default and now soft-deletes by default. After the upgrade, existing auto-purge users will start preserving more data in Quick Stats rather than losing it — the safer direction of the two, but call it out. Users who want the old hard-delete behaviour can tick the new toggle once. 4 new integration tests intest_archive_purge_api.pypin the new contract: manual purge soft-deletes by default, manual purge hard-deletes whenpurge_stats=truebody flag is set, auto-purge soft-deletes by default, auto-purge hard-deletes when the settings opt-in. Existing throttle/disabled tests still pass. 11 tests total in the file, all green; 4951 in the full backend suite. i18n parity check clean across all 8 locales. - Cloud login: corrected the access-token hint to reflect that Bambu Lab no longer surfaces the token in any UI, and called out the China-region constraint explicitly (#1396) — Reporter wintsa123 filed that China-region users can't log into Bambuddy. The code path itself is fine: PR #1013 (April) already added the China-region selector to the login form and routes token validation to
api.bambulab.cn. The actual gap was documentation. The old in-appaccessTokenHintsaid "Paste your Bambu Lab access token (from Bambu Studio)" — but Bambu Studio never exposed the token in any UI, and the profile page onbambulab.comthat used to show it is gone. For China-region accounts the email/password flow is fundamentally unusable because those accounts are bound to phone numbers, not email — token login is the only path, and the hint didn't say so. UpdatedaccessTokenHintin all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW) to state that China accounts must use this path and point at the wiki for the MakerWorld-cookie retrieval procedure. Wiki pagefeatures/cloud-profiles.mdalso rewritten under "Access Token Login": adds a "Region: China must use token login" note, replaces the dead "from Bambu Studio" guidance with the working MakerWorld-cookie method (browser DevTools → Application → Cookies →token), keeps the Python-script alternative for global-region accounts, and flags that the cookie value is sensitive. No backend changes — the token-validation endpoint accepts bothglobalandchinaregions and routes to the right API host already.
Fixed
- Virtual Printer (queue / immediate / review modes): AMS data flickered or disappeared in BambuStudio between pushalls on P1S/A1 targets (#1387) — Reporter vmhomelab ran a Print Queue VP against a P1S, opened BambuStudio, and saw the External Spool only — no AMS. Toggling Auto-Dispatch (which triggers a VP restart) made AMS briefly appear, then it reverted to defaults. Proxy Mode worked fine. The earlier #1371 sticky-keys fix only handled one of two Bambu firmware incremental-push shapes: it preserved cached AMS when the incoming push omitted the
amskey entirely (H2D's common incremental shape). The reporter's P1S firmware (01.09.01.00) instead sends incrementals with theamskey present but the innerams.amsarray stripped —{ams_status: 1, humidity: 2}instead of{ams: [...], ams_status: 1}. To the previous sticky-keys check that read as "key present, leave new state alone," so the bridge cache got overwritten with the stripped blob; the slicer's next 1 Hz read sawamswith no unit list and fell back to the "no AMS" default render. Toggling Auto-Dispatch restarted the VP and got a fresh pushall in; the next P1S incremental stripped it again. (H2D rarely hits this — its incrementals typically don't carryamsat all, so #1371 alone was enough there. The reporter's same-VP-architecture pinging both an H2D and a P1S would observe the H2D works while the P1S doesn't, which is exactly the split that surfaced this.) Fix is a deep-merge applied to theamskey inside the bridge cache, mirroring the structure Bambuddy itself already does inbambu_mqtt.py::_handle_ams_data(which is why Bambuddy's own AMS display stays coherent on the same firmware): scalar fields likeams_statusandhumiditytake the new value, but theams.amsarray is merged unit-by-unit onid, each unit'strayarray is merged tray-by-tray onid, and units / trays the incremental doesn't mention survive intact from the cached full state. A tray-targeted incremental during a print like{ams: [{id: 0, tray: [{id: 0, state: 11}]}]}now updates that one tray's state without nuking the other three trays' tray_type/tray_color. Helper added as_merge_ams_dictinbackend/app/services/virtual_printer/mqtt_bridge.pynext to_ip_to_uint32_le, called from the existing sticky-keys block. Three new regression tests underTestPushStatusCacheinbackend/tests/unit/test_vp_mqtt_bridge.pycover the status-only partial (the reporter's exact reproduction), the multi-AMS unit-level merge, and the multi-tray merge. The existingtest_incoming_ams_update_replaces_cached_amsstill passes — fresh full updates still take effect, the merge only protects the cache from stripped incrementals. 32 tests total in that file, all green. Verified the cross-subnet topology from the report (printer / Bambuddy / slicer each on a different /24) is incidental: the symptom is the same regardless of subnet once the partial-shape arrives; the latency just makes the "empty cache when slicer first connects" race more visible. ProxyMode is unaffected because Proxy is raw byte-forwarding rather than a cached-as-base mirror — it never had this class of bug. - Quick Stats showed Filament Cost = 0 and empty Time Accuracy on pre-upgrade data after the 0.2.4.1 stats rewrite (#1390) — Reporter IndividualGhost1905 upgraded to 0.2.4.1 (which shipped the per-event aggregation rewrite from #1378) and saw the Stats page split between consistent values (Total Prints / Print Time / Filament Used / Energy / Success Rate matched the archive list) and zero-or-empty ones (Filament Cost, Time Accuracy). Inconsistency was a migration gap: #1378 added six columns to
print_log_entries—archive_id,cost,energy_kwh,energy_cost,failure_reason,created_by_id— but didn't backfill any of them. So every pre-upgrade log entry kept NULL on all six. The new Quick Stats query sumsPrintLogEntry.cost(gets 0 for legacy data); the time-accuracy query joinsPrintArchive ON archive_id(drops every legacy run from the average). Counts and per-row fields that already existed pre-#1378 (status,duration_seconds,filament_used_grams) kept working — which is why some panels looked right and others didn't. Fix is a two-step backfill inrun_migrationsnext to the existing column-add block (DML, runs insidebegin_nested()not_safe_executesince the latter is documented "DDL only"): step 1 links each orphan log entry to its archive viaprint_name + printer_id(highest archiveidwins on tiebreak — newest matches the overwrite-then-stop shape that pre-#1378 reprints left behind); step 2 copiesarchive.cost / energy_kwh / energy_costonto the latest matching log entry per archive, but only for archives where no log entry yet carries a cost. That second clause is the idempotency anchor and also the double-count guard for users running this migration after #1378 has already written cost-bearing rows for new runs — those archives are left untouched. Earlier reprints stay NULL, matching the "first/latest writes, rest stay NULL" convention #1378 introduced. Sum across the legacy reprint chain reproduces sum-of-archive-cost exactly, so the Quick Stats Filament Cost column matches the pre-upgrade total instead of dropping to zero. SQL is plain ANSI — correlated UPDATE withLIMIT 1in the SET subquery,WHERE id IN (SELECT MAX(id) ... GROUP BY archive_id HAVING SUM(CASE WHEN cost IS NOT NULL THEN 1 ELSE 0 END) = 0)— verified end-to-end on both SQLite (4 unit tests intest_print_log_backfill_migration.py) andpostgres:16-alpine + asyncpg(live container reproduction). For the other widgets the reporter listed (Printer Stats, Filament Trends, By Material, Success by Material, Color Distribution) — those still iterate the archives list on the frontend rather than calling /stats, so they read consistent pre-upgrade data and aren't part of this fix; the inconsistency the reporter saw between Quick Stats and those widgets resolves itself once the backfill brings Quick Stats in line. - Spoolman: spool "Color Name" edits silently never saved — Bambuddy was writing to a field Spoolman doesn't have (#1357) — Reporter pgladel edited a spool's Color Name in Spoolman mode, hit Save, and saw the value snap back to the subtype on the next read. Martin shipped #1319 in May to handle "form round-trips the synth value back as if it were user input" — that fix's read/form-prefill half was correct (the
color_name_is_synthesizedflag, the blank-on-synth form init), but the write half assumed Spoolman has acolor_namefield on Filament. It doesn't. Verified against the liveFilamentUpdateParametersschema on Spoolman 0.23.1:name,vendor_id,material,price,density,diameter,weight,spool_weight,article_number,comment,settings_extruder_temp,settings_bed_temp,color_hex,multi_color_hexes,multi_color_direction,external_id,extra— that's the lot. Nocolor_name. Spoolman's PATCH happily returns 200 for{"color_name": "Red"}and just silently discards the unknown key. Sofind_or_create_filamentwas either patching a void or creating filament after filament with the same field-that-doesn't-stick (which is what produced the reporter's "BB also created a bunch of new filaments" trail of duplicates on each save attempt). The fix takes the same route as the existing BambuStudio slicer-preset storage: persist color_name onspool.extra.bambu_color_nameas a JSON-encoded string, register the extra field viaensure_extra_fieldbefore write (Spoolman 400s on unknown extra keys), and read it back in_map_spoolman_spoolwith priorityspool.extra.bambu_color_name → filament.color_name (forward-compat for any future Spoolman release that adds it) → subtype synth. Also dropped the now-deadcolor_namepassing throughfind_or_create_filamentandcreate_filament— Spoolman would discard it anyway and keeping the dead pipe risked the same confusion the next time someone reads this code. The previous "match by name then patch color_name" loop is gone; what survives is the name-match resilience added earlier this turn so an AMS-sync-created filament named"Glow"still matches the user-driven edit's composed"PLA Glow", which prevents the duplicate-filament trail. The frontend form'scolor_name_is_synthesizedhandling is unchanged — that part already worked. Tests rewritten across the three affected suites (test_spoolman_inventory_methods.py,test_spoolman_inventory_helpers.py,test_spoolman_inventory_api.py) to pin the new contract: filament patch never carriescolor_name, route writes tobambu_color_nameextra, read prefers extra over filament-field over synth. Verified end-to-end against the live Spoolman instance at the reporter's setup (PATCH /filament with color_name → field absent from response; PATCH /spool with extra.bambu_color_name → field present in response). - Add Smart Plug (HA mode) — search dropdown let users pick entities the schema would reject, surfacing as a cryptic regex error on Save (#1388) — Reporter MartinNYHC opened the Add Smart Plug dialog, typed a search prefix matching a multi-entity HA device (a Shelly-style outlet exposing one
switch.*and severalsensor.*/binary_sensor.*siblings under the same friendly-name prefix), clicked one of the entities, filled in the optional power/energy sensors, and clicked Save. The backend returned 422 with the raw Pydantic messageString should match pattern '^(switch|light|input_boolean|script)\.[a-z0-9_]+$'. After the dropdown closed and the search cleared, the entity-list refetch (with no search param) returned the default-domain-filtered list — which didn't include the user's pick — soselectedEntity = haEntities.find(...)was undefined, the field rendered as visually empty (placeholder shown), buthaEntityIdstill held the bad value the user had selected. Root cause was atbackend/app/services/homeassistant.py::list_entities: when a search query was present, the function bypassed the domain filter entirely and returned matches across every HA domain — including ones theSmartPlugBase.ha_entity_idregex atbackend/app/schemas/smart_plug.py:17could never accept. Offering a clickable choice the user can't save is broken UX; the fact that the error message then saidswitch|light|input_boolean|scriptmade it look like a schema problem rather than a search-permissiveness problem. Fix: the allowed-domains filter ({"switch", "light", "input_boolean", "script"}, kept in sync with the schema regex) now always runs, and search composes on top of it as an additional substring match againstentity_idorfriendly_name. Whitespace-only search strings are treated as no search. Verified the smart-plug code path is unchanged between 0.2.4 and 0.2.4.1 — this bug was latent since the script-domain commit in February 2026 and was only noticed now because the reporter hadn't reopened the modal in months. 5 new regression tests inbackend/tests/unit/services/test_homeassistant_list_entities.pycover the no-search baseline, the search-still-domain-filters case (the actual #1388 reproduction), the entity_id-or-friendly_name substring match, case-insensitivity, and the whitespace-only edge case. - H2S with no AMS could not start a print — firmware rejected the dispatch with
07FF_8012"Failed to get AMS mapping table" (#1386) — Reporter krootstijn (H2S + no AMS) clicked Print and got an immediate firmware error. Two stacked misclassifications had quietly added H2S to the dual-nozzle code paths over time. The first was instart_print_jobatbackend/app/services/bambu_mqtt.py:3168— theis_h2dflag was set true for("H2D", "H2D PRO", "H2DPRO", "H2C", "H2S", "X2D"). That single flag controlled both the firmware bool→int format (legitimately needed for the whole H-family) and the external-spool routing branch (ext_ams_id = tray_id if is_h2d else 255) which is only correct for actual dual-nozzle printers. With no AMS, the external-spool sentinel is254; the dual-nozzle branch wroteams_id=254intoams_mapping2instead of the canonical255. The exact failure shape (07FF_8012) is even called out in the comment six lines above the bad line — H2S was getting routed straight into the path the comment warned against. The second misclassification was the use_ams=False fallback atbambu_mqtt.py:3213(if ams_mapping and use_ams and not is_h2d) — meant to skip the safety drop on dual-nozzle printers whereuse_amscontrols nozzle routing — also skipped H2S, so the firmware never got a chance to fall back to external-spool mode. A third site atbambu_mqtt.py:3987(and its sibling atbackend/app/api/routes/kprofiles.py:119) classified dual-nozzle by serial prefix("094", "20P9", "31B8B"), which is wrong because H2S shares prefix094with H2D. Fix splits the conflated flag into two:is_h_family(firmware-format gate, includes H2S) andis_dual_nozzle(routing/use_ams gate, excludes H2S; prefers the runtime_is_dual_nozzleflag set fromdevice.extruder.infoand falls back to model name for the brief window right after connect). The K-profile delete and the edit route now use the same two-source check instead of the serial prefix. Empirically verified across 9+ stored H2S support bundles (nozzle_count: 1in every one) and the reporter's bug log (07FF_8012immediately after dispatch). Four new regression tests:test_h2s_single_external_spool_uses_main_id,test_h2s_no_ams_forces_use_ams_false,test_h2s_keeps_integer_format_for_calibration_fields, plus a newtest_h2s_uses_single_nozzle_formatin the K-profile suite. The K-profile detection tests were also updated to set both model name and runtime flag rather than relying on serial prefix, since the source-of-truth has shifted.
[0.2.4.1] - 2026-05-16
Added
-
Docker: opt-in system trust store for self-signed CA certificates (#1431, contributed by @WizBangCrash, requested in #1289) — Reporter runs a private LAN with self-signed certificates for internal HTTPS endpoints (his Home Assistant instance being the canonical case) and wanted Bambuddy to trust those CAs without disabling TLS verification end-to-end. Bambuddy talks to Home Assistant via
httpx.AsyncClient(backend/app/services/homeassistant.py:46) with defaultverify=True, which under httpx 0.28 means "usecertifi's CA bundle and nothing else" — so manually copying a CA file into the container had no effect. The fix is opt-in and container-side only: settingUSE_SYSTEM_TRUST_STORE=<any non-empty value>in the composeenvironment:block, combined with mounting the user's CA file(s) into/usr/local/share/ca-certificates, makes the entrypoint runupdate-ca-certificates --freshat startup andexport SSL_CERT_DIR=/etc/ssl/certs. httpx 0.28 explicitly honours that env var (_config.py:ssl.create_default_context(capath=os.environ["SSL_CERT_DIR"])), andupdate-ca-certificatespopulates/etc/ssl/certswith the Debian system CA bundle (Let's Encrypt, DigiCert, GlobalSign, etc.) plus the user-mounted CAs — so standard endpoints (api.github.com, MakerWorld, Bambu Cloud) keep working alongside the user's self-signed CA. Theca-certificatesapt package is added to the Dockerfile soupdate-ca-certificatesexists in the image. The feature is default-off — when the env var is unset the entrypoint logs a one-line "skipping system trust store update" and goes straight to the existing PUID/PGID chown path, so non-users see zero behaviour change. Fail-fast on misconfig: ifUSE_SYSTEM_TRUST_STOREis set but the container is running as non-root (the entrypoint can't write/etc/ssl/certswithout root), or/usr/local/share/ca-certificateshas no.crtfiles mounted, orupdate-ca-certificatesis missing from the image, or the trust-store rebuild itself fails, the entrypoint exits 1 with a clear error message rather than silently succeeding and leaving the user wondering why their HA connection still rejects the cert. Compose template update:docker-compose.ymlships commented-out examples for both the volume mount (/path/to/certs:/usr/local/share/ca-certificates) and the env var (USE_SYSTEM_TRUST_STORE=true) so the path from "I have a self-signed CA" to "Bambuddy trusts it" is two uncommented lines. Caveat worth flagging in docs: the feature requires the container to start as root so the entrypoint can runupdate-ca-certificates; users who pinuser: "1000:1000"in compose get the clear "not running as root" exit with the reason, but they need to switch to the default PUID/PGID-style invocation to use this. Companion wiki PR documents the setup walkthrough at maziggy/bambuddy-wiki#31. Hardware-only path (shell entrypoint change) so no automated test — verified by the reporter's local install. Post-merge polish: the fatal-exit branch's log line was relabeled from "warning: update-ca-certificates failed:" to "error: update-ca-certificates failed" to match severity and the surrounding error messages. -
Print labels: sort by colour as an alternative to spool-ID order (#1410, requested by @elit3ge) — Reporter asked for an option to order the printed label sheet by colour instead of spool number so a multi-colour roll of Avery sheets / box labels groups related colours together physically. The label-render backend (
labels.py) already honoured caller order — bothPOST /inventory/labelsandPOST /spoolman/labelspreserve the order ofspool_idsin the request body and pass it straight to the PDF renderer — so the fix is frontend-only.LabelTemplatePickerModalgains a smallsortModetoggle ("By ID" / "By colour") rendered as a chip pair next to the material-filter row. The "by colour" mode converts each spool'srgbato HSL and returns a[bucket, position]sort key: chromatic colours (saturation ≥ 0.1) go in bucket 0 ordered by hue 0..360 so the sheet reads as a continuous rainbow; achromatic colours (greys, blacks, whites, plus missing/invalid rgba) go in bucket 1 ordered by lightness so the neutrals trail the rainbow black → white. Multi-colour spools sort on their primaryrgba— the secondaryextra_colorsstripe still renders on the printed label but doesn't drive the sort, since multi-tone sorting would need a perceptual-distance model the use case doesn't justify. Stable tiebreaker on spool ID keeps identical-colour spools in a deterministic order across renders. The previous[...selectedIds].sort((a, b) => a - b)at submit time was forcing every PDF to ID order regardless of any frontend sorting — that's been replaced withsortedSpools.filter(s => selectedIds.has(s.id)).map(s => s.id)so the visible order flows through to the wire. Session-only state — toggle resets to "By ID" each time the modal opens, no persisted setting (label printing is a rare action and the user picks what they want every time). i18n: 3 new keys (inventory.labels.sortBy.{label, id, color}) translated across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW); parity check holds at 4852 leaves per locale. Tests: two new inLabelTemplatePickerModal.test.tsx— one asserts the "By colour" toggle reorders the submit payload to[Red, Ivory, Blue, Black](hue 0° / 33° / 240° then neutral with lightness 0) using the existing 4-spool fixture, the other guards the default "By ID" path so adding the toggle didn't quietly regress users who never click it. 17 modal tests green; frontend build clean. -
Camera: in-app diagnostic for "Connection lost" (#1395 follow-up) — Second step of the camera architecture overhaul. When the camera viewer hits its error state, a new Diagnose button next to Retry runs a staged check against the printer and renders the result inline: which stage failed, how long it took, and a translated remediation hint. Cuts off the "user opens a 'camera broken' ticket → wait days → ask for the support bundle → finally figure out it was their reverse proxy / LAN-only toggle / wrong access code" loop at the user's screen. Backend ships
backend/app/services/camera_diagnose.py(orchestrator) and a newPOST /printers/{id}/camera/diagnoseroute incamera.py. Stages: (1)tcp_reachable— opens a TCP socket to the camera port (322 RTSPS / 6000 chamber image) with a 3-second timeout; distinguishes timeout (tcp_timeout→ "printer not reachable, check IP/network/power") from refused (tcp_refused→ "camera port closed, check LAN-only and developer mode") from host-unreachable (tcp_unreachable→ "printer not reachable"). (2)first_frame— captures one JPEG end-to-end via the existingcapture_camera_frame_bytespipeline (15-second timeout, same code that powers/camera/snapshot); auth, RTSP handshake, and first keyframe collapse into one stage because the user-facing answer is the same regardless of which sub-layer failed. Live-stream shortcut: when a viewer is currently watching the printer's camera AND the buffered last-frame timestamp is fresher than 10 seconds, the diagnostic skips the real test and returnslive_stream_active_healthy— opening a fresh socket would kick the live viewer off on single-camera-connection firmwares (the #1348 reconnect-storm trigger), so we trust the real-world evidence instead. Response includes structured metadata for support triage:protocol(rtsp / chamber_image),port,profile(defaultor the model name with an override — currently onlyP2S), per-stage duration in ms, and the machine-readable summary code. Frontend addsCameraDiagnoseModal.tsxthat fires the API call on mount, renders one row per stage with green-check / red-X / grey-skipped icons, and shows the summary remediation message in a bordered banner styled by overall status. The metadata line at the bottom (protocol / port / profile) lets support triage ask "what does your modal say?" instead of "send the support bundle". A Run again button re-runs the diagnostic without dismissing the modal. EmbeddedCameraViewer error state grows the Diagnose button (kept "Retry" as the primary action; Diagnose is the escape hatch for users who can't see what's wrong). A small stethoscope icon also lives in the viewer's always-visible control bar between Refresh and Fullscreen, so pre-flight testing ("did my firmware update break the camera?", "is the camera up before I send a print?") doesn't require waiting for the stream to fail first. Also lifted the previously-hard-coded "Camera unavailable" / "Retry" strings intocamera.unavailable/camera.retryso the error UI is properly translated alongside the new keys. i18n: 16 new keys (unavailable,retry, plusdiagnose.{button,modalTitle,running,runFailed,retry,stage.*,summary.*,meta.*}) translated across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). German "Diagnose" is a real cognate — added toIDENTICAL_TO_EN_ALLOWED.derather than translated to a synthetic. Parity check holds at 4849 leaves per locale. Tests: 11 backend unit tests intest_camera_diagnose.pycover the live-stream shortcut (skip when fresh, run when stale), the three TCP failure modes (timeout / refused / OSError) → distinct summary codes, the first-frame stage (no-frame and capture-exception cases), the all-OK path, and the result metadata (P2S → P2S profile / rtsp / 322; A1 → default / chamber_image / 6000; X1C → default / rtsp / 322). 1 backend integration test pins the route's response shape end-to-end. 3 frontend tests inCameraDiagnoseModal.test.tsx(mounted → API call, failure → translated remediation, Run again → re-call). 5021 backend tests + 1905 frontend tests green; ruff clean; build clean; i18n parity clean.
Changed
- Inventory: AMS Filament Label Holder presets fixed and split into "small" and "large" variants (#1426, reported by @bsaunder) — Reporter (the same person who originally requested the labels feature in #809) discovered that the
ams_30x15preset's 30×15 mm dimension didn't fit any documented variant of MakerWorld model 752566, despite the preset advertising itself as designed for it. Two new presets replace it:ams_holder_74x33(matches the printable label STL bundled in the project) andams_holder_75x55(fits the cardstock-insert variant the reporter validated as "fits perfectly"). Both land in the roomy-layout branch (swatch + QR + multi-line text with brand / material / hex / spool ID) because the height crosses the 20 mm threshold — so the larger AMS holder labels carry the QR code back to/inventory?spool=<id>that the old 30×15 preset couldn't fit. The 30×15 preset is removed entirely; no DB migration needed because the preset name was never persisted (label printing is a one-shot action and the picker defaults to nothing). The legacy tight-layout code path in_draw_label_tightis kept as the safety branch for any future ultra-small preset (no shipped template uses it now). API change:POST /inventory/labelsandPOST /spoolman/labelsaccepttemplatevaluesams_holder_74x33andams_holder_75x55instead ofams_30x15. The Literal types inbackend/app/api/routes/labels.pyandfrontend/src/api/client.ts::SpoolLabelTemplatereject the old value at validation time, so any caller still scriptingams_30x15gets a 422 with a clear "valid values" message. i18n: replacedinventory.labels.templates.ams.{label,hint}withinventory.labels.templates.amsHolderSmall.{label,hint}andinventory.labels.templates.amsHolderLarge.{label,hint}— real translations across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW), the English-fallback strings on the old key are gone (feedback_translate_dont_fallbackrule). Parity check passes at 4856 leaves per locale. Tests:test_label_renderer.py::ALL_TEMPLATESupdated and the AMS-specific render test pins the small variant;test_labels.pyintegration test covers both new presets end-to-end;LabelTemplatePickerModal.test.tsxupdated to expect 6 template buttons in the grid (was 5), pin the newams_holder_75x55template value in the API call assertion, and verify both AMS variants are disabled when no spools are selected. 35 affected backend tests + 17 frontend modal tests green; full suite 5035 green; ruff clean; frontend build clean.
Fixed
-
Archives: "Scan for timelapse" no longer permanently disabled on VP-queue-dispatched prints (#1403 follow-up, reported by @pwostran and @enjoylifenow) — Reporters dispatched a print from a slicer via the VP print queue, the printer recorded the timelapse to its SD card (visible and downloadable via Bambuddy's file browser), but the archive UI's "Scan for timelapse" and "View Timelapse" actions stayed greyed out forever. Frontend gates both on
archive.printer_id(ArchivesPage.tsx:459); backend/archives/{id}/timelapse/scanalso 400s when the archive has noprinter_id. Root cause was inmain.py::on_print_start's expected-archive branch: VP-queue archives are created withprinter_id=Noneat queue-add time (we don't yet know which printer will run the job — the scheduler decides later for "Any P1S"-style queue items, and even for explicit-printer queue items the archive is created before dispatch). When the print actually starts andon_print_startlooks up the expected archive via_expected_prints, the branch updatedstatus,started_at, andsubtask_idbut never assignedprinter_id. So the archive stayedprinter_id=Nonefor the entire print and forever after — and every downstream UI / API path gated on it (timelapse scan + view, the printer filter on Archives, per-printer stats, success-rate cohort attribution) treated the archive as "unassigned." One-line fix in the expected-archive branch setsarchive.printer_id = printer_idwhen they differ, guarded against clobbering an already-correct value so library-file-based queue items (which create their archive with the printer pre-assigned at dispatch time) are unaffected. Two regressions in a newtest_print_start_assigns_printer_id_to_vp_archive.py:test_expected_archive_path_assigns_printer_id_when_unset(VP-queue archive withprinter_id=None→ promoted to the running printer) andtest_expected_archive_path_preserves_existing_printer_id(library-file archive withprinter_id=7stays at 7 when the same printer runs it — the branch is idempotent on correct data). The 2 existingtest_layer_timelapse_expected_archive.pytests + 19test_print_start_expected_promotion.pyregressions still green — the expected-archive branch's other side-effects (timelapse session, AMS mapping, status/started_at promotion) are unchanged. 5033 backend tests + ruff clean. Note about the queue-mode slicer-inheritance path itself: pwostran's specific complaint that the original #1403 fix "didn't work" was a misdiagnosis on his end — his support bundle proves Bambuddy correctly senttimelapse: trueto the printer and the printer recorded the video; his actual gap was this archive-attachment bug. The slicer-inheritance branch added in #1403 is a no-op for OrcaSlicer's "Send to print queue" flow because Orca only does the FTP upload and never sends aproject_fileMQTT command at upload time — so the path falls back todefault_*settings, which is the operative path for that workflow. Users who want a different per-print value still edit the queue item before starting (as pwostran did, which is why his dispatch carriedtimelapse: true). -
SpoolBuddy: NFC reader works again on Raspberry Pi 5 (#1424, reported by @flom89) — Reporter on a Pi 5 installed SpoolBuddy successfully but couldn't talk to the PN5180 NFC module (the gauge worked, so SPI hardware and wiring were fine). Manually commenting out
self._spi.no_cs = Truein the daemon restored communication; reporter wasn't sure whether removing it would regress Pi 4 installs. Root cause: Pi 5 uses the new RP1 southbridge and its kernel SPI driver (spi-rp1) doesn't accept theSPI_NO_CSioctl the same way the historical Broadcom driver on Pi 4 did — settingno_cs = Trueon Pi 5 either errors out or silently leaves the bus in a state where transfers don't complete. Safe to drop Pi-wide, not just Pi 5 — SpoolBuddy's PN5180 NSS line is wired to GPIO23 (manual chip-select handled by_cs_low()/_cs_high()around every transfer, because the kernel's default 5µs setup / 100µs hold timing doesn't meet the PN5180's spec). The hardware CE0 line (GPIO8) is not connected to the reader, so whether the kernel auto-toggles it duringxfer2()is electrically invisible to the PN5180. Theno_cs = Truecall was a "be polite to the bus" gesture that was always cosmetic on this hardware. Fix wraps the assignment intry / except OSErrorin bothspoolbuddy/daemon/pn5180.py(logs at debug level) andspoolbuddy/scripts/read_tag.py(silent — it's a diagnostic script with no logger). Try/except over a hard delete because Pi 4 installs that work today shouldn't see any behaviour change. README updated atspoolbuddy/README.md:23-28to drop the "spidev.no_cs = True resolves this" sentence in favour of explaining that manual CS via GPIO23 carries the timing on its own and that Pi 4 + Pi 5 are both supported. Hardware-only path so no automated test — verified by the reporter's bench test that commenting the line out restores reads. Ruff clean. -
Cover thumbnails: stop hammering FTP and GitHub when a print's 3MF isn't on the printer (#1420, reported by reporter) — Reporter on a P2S running 0.2.4.1 saw two log-flooding bugs trigger together once they started a print whose 3MF wasn't on the printer's FTP storage (typical SD-card-only print). (1) Cover endpoint had no negative cache.
GET /printers/{id}/covercached successful 3MF thumbnail downloads in_cover_cachekeyed by(subtask_name, view_key), but never recorded failures. When all 8 candidate FTP paths returned550 Failed to open file, the endpoint raised 404 without remembering that it just tried — and sincecover_urlstays populated on everyPrinterStatusresponse while state is RUNNING/PAUSE, every React-Query refetch and every component remount drove the frontend to re-fetch, replaying the same 8-path FTP fan-out roughly every few seconds. On the user's hardware the printer's single FTP socket was so busy with these doomed retries that it surfaced as camera-stream symptoms ("ffmpeg didn't terminate gracefully"). Fix adds a parallel_cover_404_cache: dict[int, set[tuple[str, str]]]that records the same(subtask_name, view_key)key on every 404 path — both the all-FTP-paths-failed branch and the 3MF-has-no-thumbnail-inside branch. On the next call for the same key, the endpoint short-circuits to 404 before even consulting FTP. The negative cache is cleared inclear_cover_cache()alongside the positive cache, whichmain.py::on_print_startalready calls — so when the next print starts (different subtask, or same subtask after a re-upload of a new file) Bambuddy retries fresh. (2) GitHub update-check had no backoff on 403 rate-limit. Onceapi.github.comreturned403 rate limit exceeded(typical when multiple Bambuddy instances or other tools share a NAT'd source IP and exhaust the unauthenticated 60-req/hr quota), the next call hit GitHub again immediately. Fix adds module-level_github_rate_limit_untilepoch-seconds plus three helpers inupdates.py:_seconds_until_github_unblocked(),_record_github_rate_limit(response)(readsX-RateLimit-Resetfrom the 403, falls back to a 1-hour pause when the header is absent or unparseable, and only extends the window — never shortens it via an out-of-order response), and_is_github_rate_limit_response(response)(status 403/429 withX-RateLimit-Remaining: 0, body-text fallback when proxies strip the header). Both call sites —GET /updates/checkand the in-app updater's_discover_target_release— short-circuit when the window is active; the route surfaces a structured{error: "GitHub rate limit reached...", retry_after_seconds: <int>}response so the SettingsPage UI can show a real wait time instead of an opaque "failed to check for updates". The "ffmpeg didn't terminate gracefully" warning line the reporter quoted is the standard SIGTERM → 2s wait → SIGKILL pattern incamera.py::_terminate_ffmpeg— RTSP/TLS streams routinely take >2s to drain and that warning fires for many users with no FTP issues; once the cover loop is silenced the resource pressure is gone, and the warning itself is cosmetic. Tests:test_cover_negative_cache_skips_repeat_ftp_fanoutintest_printers_api.pymocksdownload_file_try_paths_asyncto return False, calls the endpoint twice, and asserts the second call's FTP mock count is unchanged (the negative cache held);test_check_backs_off_after_github_rate_limitintest_updates_api.pypatcheshttpx.AsyncClientto return a 403 withX-RateLimit-Resetset 10 minutes ahead and asserts the second/updates/checkrequest never reaches httpx and surfacesretry_after_seconds > 0. 134 printers + updates integration tests green; ruff clean. -
Assign Spool: printer card refreshes immediately, no Force-refresh needed (#1414 follow-up, reported by @snozzlebert) — After assigning a spool via the modal, the Filament page Location column updated correctly but the Printer card kept showing "Empty slot (External Slot 1)" until the user manually pressed Force-refresh. The MQTT command itself was going through fine; the gap was on the client side.
AssignSpoolModal's twouseMutation.onSuccesscallbacks invalidated the inventory / slot-assignment queries (Filament page reads from those — correct) but never invalidated['printerStatus', printerId]and never issued apushallto make the printer republish its state. For Bambu RFID-tagged spools the printer echoes the newtray_typeover MQTT on its own and the websocket push would eventually surface it, but for non-RFID spools and A1 mini external slots (reporter's case) the firmware doesn't volunteer that state change, so the card sat on staletray_type: ""andgetEmptySlotKind()rendered the "Empty slot" path. Fix adds anudgePrinterRepublish()helper called from bothonSuccesspaths (internal-inventoryassignMutationandassignSpoolmanMutation): callsapi.refreshPrinterStatus(printerId)to issue the pushall (same call the Force-refresh button uses atPrintersPage.tsx:1844) and invalidates['printerStatus', printerId]so the refetch lands. Failures fromrefreshPrinterStatusare deliberately swallowed — the assignment itself already succeeded, and if the refresh nudge is offline the next regular poll / websocket update will catch up; we don't want to surface a misleading "assign failed" toast for a stale-cache cleanup that didn't go through. Mirrors the patternConfigureAmsSlotModalhas used since #1235 (line 5346) but with the extra pushall step because assign-spool affects firmware-side state where configure-slot affects only client-side preset mapping. Same fix covers both inventory modes thanks to the shared helper. Tests: newnudges the printer to republish after successful assignment (#1414)inAssignSpoolModal.test.tsx— picks a material-matching spool to bypass the mismatch-confirm dialog, clicks the Polymaker spool card to select it, clicks "Assign Spool", assertsapi.refreshPrinterStatus(7)was called.printerId=7(not the default 1) verifies the helper threads the prop value through correctly rather than hardcoding. The api mock at the top of the file gainsassignSpoolmanSlot,getSpoolmanSlotAssignments, andrefreshPrinterStatusso the existing 13 tests still pass alongside the new one. 14 modal tests + build clean. -
FTP: tolerate transient 426 from buggy printer FTP when the file is actually on the SD card (#1417 follow-up, reported by @enjoylifenow) — In the previous daily build, commit
1fac0276tightened the post-STOR confirmation handler inbambu_ftp.pyso that anyftplib.Errorfromvoidresp()(includingerror_temp 426"Failure reading network stream") would fail the upload outright. The goal was to stop Bambuddy from sending a print command for a truncated 3MF when the printer's FTP server explicitly told us the data stream was cut — exactly the scenario surfacing the user's earlier "unable to parse 3mf file" 30 s into a print. Reporter then confirmed (after running a filesystem check + reformat + power cycle, all clean) that the same install worked fine on v0.2.4.1 — proving that for the specific P2S firmware revision in question, the 426 is noise: the TLS data-channel close races the 226 confirmation, the server reports failure on voidresp, but the file did land fully on the SD card. The previous proceed-with-warning behaviour was accidentally correct for that firmware quirk. Reverting wholesale would re-introduce the silent-truncation bug, so instead narrow the rule: when voidresp raises anftplib.Error, immediately follow up with an FTPSIZEquery against the freshly-uploaded path. If the server-side size matches what Bambuddy just sent, the file is provably intact and Bambuddy proceeds with a warning (FTP STOR returned error_temp for X but file is intact on the printer (N bytes match) — proceeding). If the size doesn't match — orSIZEitself raises — the transfer was genuinely truncated (or the server is in too broken a state to be trusted) and the upload fails loudly with the error log path the previous round added (server size=... expected=...). Same logic is applied to bothupload_file()andupload_bytes()so the legacy A1-compatibility manual-transfer path is covered identically. Tests: the two existing regressions from the previous round (test_upload_426_data_stream_failure_returns_false,test_upload_bytes_426_data_stream_failure_returns_false) are renamed and split:test_upload_426_with_intact_file_proceeds(SIZE matches → returns True, the reporter's case),test_upload_426_with_truncated_file_returns_false(SIZE smaller than expected → still fails, the original bug we don't want to regress),test_upload_426_with_size_check_failing_returns_false(SIZE itself raises → assume the worst), plus parallel coverage forupload_bytes(). The intact-file tests have to injectSIZEexplicitly because the pyftpdlib mock only flushes the on-disk file after a clean voidresp — which doesn't happen when we monkeypatch voidresp to raise — and the docstring spells that out for future readers. 87 FTP unit tests green; ruff clean. The View-Timelapse-greyed-out behaviour the original #1417 report flagged stays untouched here — once the reporter confirms their upload reliability is back, that diagnosis continues on a healthy install. -
AMS: physically-empty slots now consistently report state=9 (#1322 follow-up, diagnosed by @RosdasHH) — Reporter dug into the BambuStudio source and pointed out that our previous fix only caught the narrow
{"id": N}bare-payload shape, which Bambu firmware only sends right after a printer restart. In steady-state operation — including the more common post-Reset-Slot path on P1S and the A1 Mini BMCU — firmware sends a populated payload with stale fields and signals emptiness via thetray_exist_bitsbitmask instead. Bambuddy already parsed that bitmask atbambu_mqtt.py:1758(slot_exists = (tray_exist_bits >> global_bit) & 1) and used it to wipe staletray_type/tray_color/tag_uidfields, but never promoted the slot'sstate. So downstream readers — the API serializer atprinters.py:457, thetray_state in {9, 10}short-circuit ininventory.py:1358, the AMS card — all sawstate: nulland had to guess from absent payload fields. Reporter's API screenshot showed exactly that shape:state: null, tray_color: null, remain: 0, .... Fix lifts atray["state"] = 9assignment to the outerif not slot_existsbranch (was nested inside the stale-data-clear branch), so the bitmask path now writes the canonical "no spool" state for every empty slot regardless of whether stale fields are present. Hard-typed asint9, not string"9"— the downstream check atinventory.py:1358usestray_state == 9(notin {"9", 9}), so a string would have silently missed and the reporter's deadlock would have come right back. The previous narrow heuristic inprinter_manager.py:797-798(thelen(tray) == 1 and "id" in trayshape detector) stays in place as belt-and-suspenders for the post-restart bare-payload edge that bypasses the AMS merge — costs nothing and protects against any MQTT path that doesn't flow through_handle_ams_data. Thestate: nullsurfacing on the API resolves automatically sinceprinters.pyreadstray_data.get("state")directly. Tests: two new intest_bambu_mqtt.py::TestAMSDataHandling—test_tray_exist_bits_promotes_empty_slot_to_state_9exercises the steady-state populated-payload path (slot occupied → bitmask flips bit 1 to 0 → state=9, type-asserted as int; loaded sibling slot keeps its state=11 unchanged);test_tray_exist_bits_does_not_change_state_on_loaded_slotspins the negative path (bitmask bit=1 with state=3 leaves state untouched — transitional firmware states like "unloading" don't get corrupted). The twoprinter_manager.pyregression tests for the narrow heuristic (test_bare_tray_emulates_state_9,test_populated_payload_with_empty_state_3_is_not_promoted) stay green — that path is unchanged. 397 mqtt+printer-manager unit tests + 50 inventory/Spoolman slot-assignment integration tests = 447 affected tests green; ruff clean. UI: visual distinction between physically empty and unconfigured slots (in the same drop). With the data layer now consistent, the AMS slot card surfaces what Bambuddy actually knows about each empty slot, without overclaiming. New helpergetEmptySlotKind(tray)inPrintersPage.tsxreturns"physical"(state ∈ {9, 10} — firmware positively confirmed no spool),"reset"(any other empty state — could be a user-cleared assignment, mid-unload, or just a slot the firmware hasn't reported on yet), ornull(loaded). The inline label below the slot circle readst('ams.slotEmpty')("Empty") uniformly for any empty slot (regular AMS, HT, external) so users get a consistent label everywhere — the previous version only said "Empty" for firmware-confirmed state=9 slots and fell back to an em-dash otherwise, which surfaced as "Empty" for regular AMS slots but "—" for HT AMS (skipped by the bitmask loop) and external trays (separate MQTT path entirely). The state distinction now lives only on the border and hover card where it doesn't surprise.FilamentSlotCirclegains anemptyKindprop that picks a quieter dashed border colour for unconfigured slots (#3d3d3dvs#666), so the visual hierarchy reads "loaded > unconfigured > physically empty" at a glance even though the inline text only differentiates physical from everything else.EmptySlotHoverCardgains akindprop and switches the hover label betweenams.emptySlot("Empty slot") for physical and the newams.emptySlotReset("No filament assigned") for everything else — also rewritten from the original "Slot reset — no spool assigned" for the same overclaim reason. All three slot-render call sites inPrintersPage(regular AMS grid, HT AMS single-slot, external spool tray) now compute and pass the kind. i18n: 2 new keys (slotEmpty,emptySlotReset) translated across all 8 locales; parity at 4854 leaves. New test#1322: empty slot kind is "physical" when state=9 and "reset" otherwiseinPrintersPage.test.tsxreuses the existingphase13EmptySlotPropsmock to capture thekindprop across a 4-slot fixture (state=9 / state=3 / state=null / loaded) and asserts each variant flows through. 71 PrintersPage + FilamentHoverCard tests green; build clean. -
Stats page: Filament Used, By Time, and Success Rate now agree with Total Consumed and Total Prints (#1390 follow-up, reported by @IndividualGhost1905) — After the archived-spool fix shipped the reporter confirmed it worked and gently flagged the round Bambuddy had explicitly postponed: Quick Stats
Filament Used/Filament Costdidn't matchTotal Consumedon the Inventory page; Printer StatsBy Timedidn't match Quick StatsPrint Time; the success-rate gauge percentage didn't relate to theTotal Printscount shown right above it. Three independent root causes, fixed together. (1) Filament Used vs Total Consumed._compute_run_filament_gramsinmain.pyshort-circuited to the slicer estimate forstatus == "completed"even when inventory had measured the actual AMS weight delta — the comment on the old test literally said "the print is done, so the full estimate is the right answer." That made Stats and Inventory two different sources of truth: Stats showed slicer-estimate grams, Inventory showed AMS-tracked grams, and the two numbers naturally diverged (slicer estimates are typically a few percent off real consumption). Fixed by reordering the helper so the tracked spool delta (sum ofusage_results[].weight_used— same source that drives the per-spoolweight_usedcounter behind Total Consumed) takes priority for every status. The slicer estimate stays as the fallback when no inventory was tracked for the print, and the partial-progress scale stays as the fallback for failed/cancelled/stopped with no tracker — so the existing #1378 partial-aware behaviour is preserved. The_run_costblock right next to it already used this priority order, so cost was always tracker-first; onlyfilament_used_gramswas inconsistent. New prints now record what was actually consumed, so Stats and Inventory show identical numbers. (2) Printer Stats By Time vs Quick Stats Print Time./archives/slimonly populatedactual_time_secondswhenstatus == "completed". For failed/cancelled rows the field stayed null and the frontend (StatsPage.tsx::PrinterStatsWidget) fell back toprint_time_seconds— the slicer's estimated full-print duration, which is the wrong number for a print that failed at 15% progress. Quick Statstotal_print_time_hoursalready counted every event's elapsedduration_secondsregardless of status, so the two halves of the page disagreed by the (estimate − actual-elapsed) gap on every non-completed event. Dropped thestatus == "completed"gate in the slim row'sactual_time_secondscomputation; failed/cancelled events now report their measured elapsed time and the frontend'sactual || print_timefallback chain only ever falls through to the slicer estimate for events with no measured duration at all. (3) Success Rate %. Formula wassuccessful / (successful + failed), denominator excludingcancelled/stopped/ any other status. Combined with the visible "Total Prints: N" label right above the gauge, that produced confusing numbers: 4 successful, 0 failed, 48 cancelled showed 100% out of an apparent 52 prints. Switched tosuccessful / total_prints— straightforward "what fraction of all attempts succeeded", matches the count the user reads from the widget header. The widget'sstatsprop already exposedtotal_printsso no type changes were needed. (4) Records widget "Longest Print" — knock-on from (2). Before (2),actual_time_secondswas null for non-completed rows so the Records widget'sfindMax(a => a.actual_time_seconds)implicitly only ranked successful prints. Once (2) populated the field for failed/cancelled events too, an aborted 25-hour print would have outranked a genuinely successful 18-hour print as "Longest Print" — a real semantic regression. Added astatus === 'completed'gate on the longest getter only, restoring the pre-fix semantic. The other two records (Heaviest Print, Most Expensive) already included non-completed events viafilament_used_gramsandcostand intentionally stay as-is, since those values are populated by the partial-progress / tracker logic in_compute_run_filament_gramsand were never gated on status. Out of scope — backfilling the 52 historical events on the reporter's database:PrintLogEntry.filament_used_gramsis already baked in as the slicer estimate for older prints and we don't store per-event AMS deltas separately to backfill from. The reporter said upfront she'd "reset all statistics and start over" to track new prints cleanly, so this lands without a migration. Similarly the Failure Analysis 30-day default (a separate divergence the agent surfaced while mapping the page) wasn't part of the reporter's complaints and stays untouched. Tests:test_run_filament_helper.py::test_completed_returns_estimate_even_when_tracked_differswas renamed and inverted totest_completed_prefers_tracked_over_estimate— it now pins the new contract (completed + tracker → tracker value), guarding against a future "trust the estimate again" refactor. All 13 existing helper tests still green; the helper's contract changed in exactly one place and the rest (no-tracker fallback, partial-progress scaling, multi-slot summation) is unchanged.test_archives_api.py::test_slim_actual_time_null_for_failedwas renamed totest_slim_actual_time_for_failed_includes_elapsedand inverted — same pattern, the old assertion is now the regression check.StatsPage.test.tsxgains two:uses total_prints as denominator so cancelled/stopped events count (#1390)(40 successful / 20 failed / 40 cancelled-or-stopped = 40%, matches Total Prints: 100, where the old formula would have shown 67%); andLongest Print excludes failed prints (#1390)pinning that an aborted 25-hour run can't outrank a successful 8-hour print as the Longest Print record — protects against a future refactor that removes the new status gate insidefindMax. 33 StatsPage tests + 66 archive-API + run-filament tests green; frontend build clean. -
Inventory: "Total Consumed" now includes archived spools' usage, and the eraser works on archived too (#1390 follow-up, reported by @IndividualGhost1905) — After the original #1390 fix shipped, the reporter noticed that archiving a spool with consumed weight quietly subtracted that weight from the "Total Consumed" stat at the top of the Inventory page, and un-archiving put it back. Total Consumed is a running counter (lifetime usage since the last reset), not a current-inventory snapshot, so a spool's recorded prints SHOULD stay in the total even after the user archives the physical roll — otherwise the reset baseline becomes meaningless and the running total walks down as users tidy up their inventory. Root cause was a stats loop in
InventoryPage.tsxthat gated every aggregate (totalConsumed, totalWeight, lowStock, byMaterial, activeCount) behind a singleif (s.archived_at) continue;check. Fix splits the loop sototalConsumedis computed BEFORE the archived-skip and the other aggregates after it, matching the semantic difference between "running counter" and "currently-available inventory". Two adjacent regressions the reporter also surfaced are fixed in the same pass: (a) the per-spool eraser button in the inventory card grid used to require!spool.archived_at && spool.weight_used > 0— archived spools had no way to zero their tracking counter without first being un-archived. Thearchived_athalf of that gate is gone; theweight_used > 0half stays. (b)activeSpoolIds, the target list for the "Reset all usage" bulk action, used to filter out archived spools — so a Reset-all click left archived consumption stuck in the (now-corrected) totalConsumed total. Renamed toresetableSpoolIdsand broadened to include archived, so a Reset-all genuinely zeroes the stat in one click. Backend reset endpoints already accept archived IDs (bothinventory.py::reset_spool_usageand the Spoolman mirror), so this is frontend-only. Inventory-mode parity holds (both modes shareInventoryPage). i18n: 8 tooltip/confirm strings retranslated across all 8 locales — the "every active spool" / "all {{count}} active spools" wording was now incorrect (archived included), so each locale'sresetAllUsageTooltipdrops "active" andresetAllUsageConfirmmakes the archived-inclusion explicit ("(archived included)" / "(incluindo as arquivadas)" / "(含已归档)" etc.); parity holds at 4852 leaves. Tests: a newInventoryPageArchivedConsumed.test.tsxwith a 2-spool fixture (active 300 g + archived 500 g) pinstotalConsumed = 800gafter the fix and asserts the "Reset all spool usage" button stays rendered; a future refactor that re-introduces the archived-skip drops the assertion to "300g" and CI fails. 13 InventoryPage tests + i18n parity + build all green. -
P2S camera: relaxed ffmpeg probe settings so the RTSP stream actually locks (#1395 follow-up, reported by @Tschipel) — Reporter on a P2S running firmware 01.02.00.00 saw the camera connect for a few seconds and then time out, repeating. P1S on the same install worked fine because P1S uses the chamber-image protocol (port 6000), not RTSP — different code path. The P2S RTSP path was running ffmpeg with
-probesize 32 -analyzeduration 0, tuned for X1/H2 fast startup. The P2S's slower keyframe pacing means ffmpeg can't lock onto the stream within 32 bytes; its own stderr literally says "Stream #0: not enough frames to estimate rate; consider increasing probesize". After ~2 s ffmpeg gives up, Bambuddy reconnects, the cycle repeats. The naïve "just bump probesize" patch would regress every other RTSP-capable printer, so the fix is also the first step of the camera architecture overhaul: per-model tuning lives in a newbackend/app/services/camera_profiles.pyregistry instead of hard-coded module constants.CameraProfiledataclass holds the previously-global knobs (probesize,analyzeduration,rtsp_reconnect_max,rtsp_reconnect_delay, plus anextra_ffmpeg_input_argshook for future per-model flags);get_camera_profile(model)returns the model's profile or the default. The default profile preserves the historical X1/H2 fast-startup values verbatim — X1, X1C, X1E, X2D, H2C, H2D, H2D Pro, H2S all see no behaviour change. P2S gets the only override today:probesize=1_000_000,analyzeduration=500_000— enough room for the slow keyframe without adding multi-second startup latency. Internal SSDP codes (e.g.N7→ P2S) resolve via an alias map so the camera path works during the early-connect window before the display name is settled. The two_RTSP_MAX_RECONNECTS/_RTSP_RECONNECT_DELAYmodule constants are gone in favour ofprofile.rtsp_reconnect_max/profile.rtsp_reconnect_delay; same defaults, but now overridable per model. Pattern is intentionally extensible — adding the next quirky model is a config entry in_PROFILES, not another global constant. Tests: 9 new intest_camera_profiles.pycover unknown model → default,None/empty → default, default preserves historical values, P2S has relaxed probe, P2S internal code (N7) resolves to P2S profile, lookup is case-insensitive, every other RTSP model still uses the default (so the next refactor regression is caught at unit-test time), profile is frozen (immutable). 58 existing camera-related tests still green; 5008 backend tests total green; ruff clean.
Changed
-
Inventory: spool ID surfaced in the edit modal and the AMS filament hover card (#1385, contributed by @chanakyan-arivumani in #1402, reported by @pgladel) — Reporter asked for the Spoolman / internal spool ID to be visible when editing a spool and when hovering the AMS-loaded filament tile, so the install can be cross-checked against the underlying spool row without opening Spoolman's UI separately. The data was already on the rendered components; only the rendering was missing.
SpoolFormModalheader now shows#<id>in muted monospace next to the "Edit Spool" title — but only in edit mode; copy and create paths don't surface an ID because no stable ID exists yet (a copy produces a new spool, and surfacing the source spool's ID there would mislead the user into thinking the new spool inherited it).FilamentHoverCard's assigned-spool block shows the same#<id>inline with the brand/material/colour line; the existing<p class="truncate">is wrapped in a flex container withmin-w-0on the parent andshrink-0on the new span so the truncation still kicks in on long names and the ID stays at full width. Inventory-mode parity holds without any branching — both internal and Spoolman spools carry anidwith the same shape so the modal renders the right ID regardless of which inventory backend is in use. Tests: one regression inFilamentHoverCard.test.tsx(asserts#42renders in the assigned-spool block) plus three added inSpoolFormModal.test.tsxas post-PR work — edit mode shows the ID, create mode shows none, copy mode shows none. The copy-mode test is the load-bearing case: a future refactor that drops theisEditing &&guard would silently start leaking the source spool's ID into the Copy header, and now fails the test instead. 51 affected frontend tests green; frontend build clean. -
Archives → Print Log: filename column expands to fit available width and wraps long names instead of clipping at 200 px (#1406, requested by @daFreeMan) — Reporter on a 27" monitor saw long filenames like
Simple_Print_Monitor_-_ST7789_1.54_display_case_truncated even though the table had plenty of unused horizontal space. The print-name<span>had a hardtruncate max-w-[200px]cap that ignored viewport width entirely. Replaced withbreak-words+ atitleattribute, dropping the explicit max-width so the column auto-sizes to content. On wide screens the full name shows on a single line; on narrow ones it wraps inside the cell instead of forcing horizontal scroll. Thetitlehover preserves the original tooltip affordance for the rare case where a really long name still gets truncated by viewport constraints. Frontend build clean; 23 ArchivesPage tests still pass.
Fixed
-
Library "Open in Slicer": broken when the display name lacked
.3mfor contained/ \ ? #(#1413, contributed by @benhalverson in #1416, reported by @ddingg) — Reporter on Windows 11 / Chrome saw Bambu Studio and OrcaSlicer reject the slicer URL from the 3D-preview modal's "Open in Slicer" button with a parse error, even though downloading the same URL with curl worked. The MakerWorld "Save and open" path and the "Recent imports" entry both worked fine — different code path. Root cause:GET /library/files/{file_id}/dl/{token}/{filename}uses the URL-tail filename purely as a hint for the slicer to detect the file format from the path; the backend itself readsfile.filenamefrom the DB (library.py:3806) and ignores the URL segment. WhenModelViewerModal.handleOpenInSlicerpassed the modaltitle(display name like"Mecha Mewtwo No AMS Multi Color Parted Statue"— no extension) verbatim throughencodeURIComponent, the resulting URL ended without.3mfand the slicer's client-side extension sniff refused to parse the response. The same path also let/ \ ? #through, which can surviveencodeURIComponent(/is unencoded by spec) and break the slicer's URL parser separately. Fix adds a smallbuildSlicerUrlFilename(filename)helper tofrontend/src/api/client.tsthat strips/ \ ? #(replacing them with_) and appends.3mfwhen missing;getLibrarySlicerDownloadUrlnow routes the filename through it beforeencodeURIComponent. The.3mfcheck is case-insensitive (safe.toLowerCase().endsWith('.3mf')) somodel.3MFis handled correctly — a subtle improvement over the equivalent inline logic that's still present on the archive-side helpersgetArchiveForSlicerandgetArchiveSlicerDownloadUrl(call-site consolidation is a separate follow-up). Safe becauseModelViewerModal.tsx:264already gates the button tofileType === '3mf'library files, so unconditional.3mfappend never produces nonsense likemodel.gcode.3mf. Two new tests infrontend/src/__tests__/api/client.test.tscover both branches (display name without extension →.3mfappended; display name with/ ? #→ replaced with_). 21 client tests green; frontend build clean. -
Add Spool modal: hex colour field can be typed into character-by-character again (#1407, reported by @anthonyma94) — Pre-fix, after typing the first hex character the input's value snapped to e.g. "A00000" (the #1055 fix aggressively padded to 8 chars on every keystroke), the cursor jumped to the end, and the next keystroke landed at position 7 — which the original 7-char-truncation branch then dropped. Net effect: only the first character was ever typed; the rest stayed as zeros unless the user pasted a full hex code. Fix splits "what the user is typing" from "what gets sent to the backend": the input now has its own draft state holding 0–6 chars freely, and
updateField('rgba', ...)only fires once the draft reaches a complete 6-char RGB (commits as<6chars>FF). On blur, a partial 1–5 char draft is right-padded with0and committed so the form state always carries a valid 8-char rgba — preserves the #1055 invariant that the backend never sees a malformed value, without re-introducing the truncate-on-keystroke trap. AuseEffectkeeps the draft in sync when an external action (the colour picker, a swatch click, edit-mode load) changes the canonical hex. Paste of 7-/8-char strings truncates to the leading RGB triplet: Bambu filaments are opaque and the UI never exposed an alpha affordance, so dropping the (undocumented) "paste with alpha" case is fine. The existingColorSectionHexInput.test.tsxwas rewritten to match the new contract — 8 tests covering both new behaviours (draft reflects each keystroke, no commit while partial, commits on length 6, blur-padding for partials, no commit when cleared then blurred) and the kept #1055 invariants (committed rgba is always 8 hex chars, 7-/8-char paste truncates, non-hex chars stripped). 51 spool-form frontend tests green; frontend build clean. -
Virtual Printer queue: timelapse / bed-leveling / flow-cali / vibration-cali / layer-inspect now inherit the slicer's choice instead of always falling back to global defaults (#1403, reported by @pwostran) — Reporter sliced in OrcaSlicer with timelapse enabled, sent to a VP queue, started the job from the queue and got no timelapse video. The dispatch chain itself was correct (queue item → scheduler → MQTT command honours
timelapse); the gap was at queue-add time: the VP's_add_to_print_queuereaddefault_timelapsefrom settings (introduced in #1235 to stop column defaults from winning), but ignored the slicer's project_file MQTT command entirely. The slicer's choice — which Bambu Handy / Bambu Studio / Orca all surface in their "Print options" dialog and ship in the MQTT payload astimelapse: true|1— was being thrown away. So a user withdefault_timelapse=false(the new-install value) would have to either flip the global setting or manually edit every queue item, even though their slicer's UI was clearly saying "record timelapse for this job". Fix:on_print_commandin the VP manager now stashes the slicer's project_file dict keyed by filename, and_add_to_print_queuewaits up to 2 s for that capture before reading the settings fallback. Each option flows through per-field — slicer value wins if present, else the existing settings default (so users who explicitly setdefault_timelapse=truein their VP workflow card still get that on slicers that don't send a print command). MQTT field naming is preserved exactly:bed_leveling(single L) on the wire stays mapped tobed_levelling(double L) on the Bambuddy column. Integer 0/1 from H-family slicers and bool true/false from P1/X1 slicers both coerce correctly viabool(). Wait is skipped when there's no MQTT server attached to the VP instance (covers unit tests calling_add_to_print_queuedirectly so they don't pay the 2 s tax) and capture is consumed on use so the dict stays bounded across many prints. Two new regressions intest_virtual_printer.py::TestPrintQueueMode:test_add_to_print_queue_inherits_slicer_print_options(slicer=True overrides settings=False across all 5 fields; capture is consumed) andtest_add_to_print_queue_coerces_slicer_integer_zero_one(H-family integer payload is coerced). The existing#1235test (test_add_to_print_queue_uses_workflow_defaults_from_settings) still passes because the no-MQTT-attached gate skips the wait, so the settings fallback path is preserved when no slicer capture exists. Plus two side-bugs surfaced while investigating Martin's "modal not respected" hypothesis — the support-package evidence cleared the reprint modal (46print_schedulerand 33background_dispatchevents withtimelapse: trueshipped to real P1S printers, end-to-end working), but the same dig turned up two latent issues worth fixing in the same pass: (a)POST /webhook/printer/{id}/startwas broken on four axes —await printer_manager.start_print(...)against adef(notasync def) function,queue_item.archive_id(int) passed as thefilenamearg,printer_manager.get_status(...).get(...)against aPrinterStatedataclass (not a dict), and every print option discarded (timelapse, bed_levelling, AMS mapping). The route would 500 before ever reaching the printer. Rewritten to mirrorPOST /print-queue/{item_id}/start: just clearmanual_start=Falseon the next pending queue item and let the scheduler dispatch it with the queue's stored options intact. Three new regressions intest_webhook_start_print.py(clearsmanual_start, preserves stored print options, 404 when no pending items / unknown printer). (b)vibration_calidefault drift inbackground_dispatch.py—ReprintRequest.vibration_caliandFilePrintRequest.vibration_caliboth default toTrue(matches Bambu Studio behaviour for X1/P1 series), but the two_process_jobcall sites readjob.options.get("vibration_cali", False). Cosmetic today because the frontend always sends the field, but a latent landmine for any future caller that bypasses the schema (e.g. an internal dispatcher seeding options programmatically). Both call sites flipped toTrue; new contract testtest_dispatch_option_defaults_align_with_request_schema_defaultsintrospects the source to lock the alignment for all six print-option fields so this drift can't recur. 116 VP unit tests green; 4999 backend tests green; ruff clean. -
Inventory: "Reset usage to 0" no longer inflates remaining weight back to label_weight (#1390 follow-up, reported by @IndividualGhost1905) — Reporter reset a 544 g spool's consumed counter and watched its displayed remaining jump to 1000 g — exactly the opposite of what the dialog promised ("Spools and remaining weights are not changed"). Root cause was an architectural conflation in the internal inventory model: a single
weight_usedcolumn was doing two jobs, the resettable "consumed since tracking started" stat AND the basis for the displayed remaining (label_weight - weight_used). Zeroing it correctly cleared the stat but unavoidably reset remaining to full. Spoolman has separateused_weightandremaining_weightfields, so its API call was correct, but Bambuddy's frontend was also computing remaining aslabel_weight - weight_usedfor Spoolman spools (ignoring Spoolman's realremaining_weightfield), so the same visual bug bit in Spoolman mode too. Internal mode fix: newweight_used_baselinecolumn (Float, default 0) on thespooltable; the "Total Consumed" display is nowweight_used - weight_used_baselineclamped to ≥0; the reset endpoints stampbaseline = weight_usedand leaveweight_useduntouched, so remaining (=label_weight - weight_used) is preserved. Subsequent prints continue to growweight_usedand the resettable counter naturally tracks the post-reset delta. Spoolman parity fix:_map_spoolman_spoolnow reads Spoolman'sremaining_weightfield and returns a syntheticweight_used = label_weight - remaining_weightso the frontend's remaining calc matches Spoolman's real stored value;weight_used_baselineis computed assynthetic_weight_used - real_used_weightsoweight_used - baselineequals Spoolman'sused_weight(the resettable counter). After a Spoolman reset (real used_weight=0, real remaining_weight=544) the user sees consumed=0 and remaining=544 — identical to internal mode. Also fixed a related Spoolman bug: editing a spool's metadata after a reset would PATCH Spoolman withremaining_weight = label - used_weight = 1000, overwriting the real 544 g;update_spoolnow derives the defaultweight_usedfrom Spoolman'sremaining_weightinstead ofused_weightso non-weight edits preserve the existing physical state. FrontendtotalConsumedaggregate inInventoryPageand the three "consumed" displays inForecastPanel(delta-rate, per-SKU totalUsedG, per-spool consumed cell) all switched toMath.max(0, weight_used - (weight_used_baseline ?? 0)). The?? 0fallback keeps pre-migration installs rendering correctly untilinit_db()runs the idempotentALTER TABLE spool ADD COLUMN weight_used_baseline REAL DEFAULT 0(works on both SQLite and Postgres). Tests:test_spool_reset_usage.pyrewritten — old asserts that the endpoint zeroedweight_usednow assert it stamps baseline = weight_used and leaves weight_used alone, plus a newtest_reset_then_print_advances_only_the_countertest that simulates a 50 g print after a reset and confirmsconsumed=50, remaining=494(i.e. remaining keeps decrementing across the reset).test_spoolman_inventory_helpers.pygets two new tests on the mapper covering pre-reset and post-reset Spoolman shapes.test_spoolman_inventory_api.py::test_reset_spool_usageupdated to assert the new InventorySpool contract (consumed=0, remaining=750, baseline absorbs the reset). 4993 backend tests green; ruff clean; frontend build clean. Per the inventory-parity rule saved last session: both modes now ship the same UX, both call sites verified end-to-end before declaring done. -
Adding a printer with a wrong access code (or unreachable IP) no longer creates an empty card — Several support reports traced back to a single root cause: the user mistyped their access code in the Add Printer dialog,
POST /printers/happily persisted the row, the subsequentprinter_manager.connect_printer()call was fire-and-forget so the failure was invisible, and the dashboard ended up showing a printer card that could never display state. The create route now runsprinter_manager.test_connection()(the same MQTT probe the standalone Test Connection button has always used) BEFORE inserting the row, and refuses with HTTP 400 if the probe fails. The Printer row is never written on failure. Structured error response: backend returns{detail: {code: "printer_connection_failed", message: "..."}}rather than a plain English string — the newApiError.codefield on the frontend lets the toast layer pick a localizedprinters.toast.connectionFailedNotAddedkey instead of surfacing the English fallback. Existing tests kept green via an autouse_mock_printer_test_connectionfixture intest_printers_api.pythat defaults the probe to success; a newtest_create_printer_rejects_when_mqtt_probe_failsasserts the failure path returns 400, surfaces the stable code, AND verifies the row was not persisted (the critical part — earlier versions of the regression would have passed even if we'd left the row behind). 8 new i18n translations forprinters.toast.connectionFailedNotAddedacross all 8 locales; parity holds at 4831 leaves. 28 printer-route tests green.
Changed
- GitHub backup: save-failure messages render inline on the card instead of as a toast — The new "repository is not private" rejection message is ~250 chars listing every credential the backup carries, which clips badly in a toast. Both the initial-setup save and the debounced autosave now stash the backend's error message into a new
saveErrorstate and render it as a red inline banner above the test-result block, withwhitespace-pre-wrapso the full message stays readable. The banner clears on a successful save, on the next save attempt, and as soon as the user starts editing the URL / token / provider (the three fields whose changes invalidate the privacy check) — so it doesn't linger after the user has already addressed the cause. Short success toasts ("Settings saved", "Token updated", "Backup enabled") are unchanged. Manual dismiss button included for users who want to clear it without retrying.
Security
- GitHub backup refuses to save against a non-private repository — While auditing real-world Bambuddy backup repos on GitHub I found several that were left public by their owners. That's a serious data leak: the settings backup only filtered
bambu_cloud_tokenandauth_secret_key, somqtt_username,mqtt_password,ha_token,prometheus_token,bambu_cloud_email,external_url, and the printer access codes (via K-profiles, which carry the serial number) were going to whatever visibility the user picked when they created the repo. Fix is a hard guard at every save and re-checked on every push:POST /github-backup/configandPATCH /github-backup/config(when the URL, token, or provider changes) run a connection test internally and return HTTP 400 unlessis_privatecomes back True. Same check fires insiderun_backup()before every scheduled or manual push, so a repository that was private at config time but later flipped to public in the provider's UI gets a clear "Backup aborted: the target repository is no longer private" failure entry instead of leaking the next backup. Implementation: each provider'stest_connection(GitHubBackend,ForgejoBackendoverride,GitLabBackendoverride;GiteaBackendinherits unchanged) now returnsis_private: bool | None—Truefor confirmed private,Falsefor public (or GitLab'sinternal),Nonefor "couldn't determine" (older self-hosted APIs, non-2xx responses). The route helper_enforce_private_reporejects anything that isn'tTrue, with separate error messages for the public case ("Make the repository private...") vs the unknown-visibility case ("...could not confirm..."). Frontend test-connection UI now renders the visibility result inline — green check + "Repository is private — safe to back up to" when confirmed, red banner with the full list of credentials at risk + "Saving is blocked until..." when public, yellow banner + "could not determine" when null. Three new i18n keys (repoIsPrivate,repoIsPublicWarning,repoVisibilityUnknown) translated across all 8 locales; parity holds at 4830 leaves. Wikidocs/features/backup.mdgains a top-level!!! danger "Private repositories only"block listing what's at stake and what to do if the user already has a public backup repo, plus every per-provider setup step is updated from "(can be private)" to "(must be private)". Tests: 5 new intest_github_backup_api.py::TestGitHubBackupPrivateRepoGuard— create rejects public (400 + "not private" in detail), create rejects unknown visibility (400 + "could not confirm"), create rejects failed test_connection (400 + propagates the underlying message), PATCH that changes the URL re-runs the check and rejects on public, PATCH that touches an unrelated field (e.g.schedule_enabled) does NOT calltest_connection(proven via a mock that raises if called — without the field-change gate, every benign toggle would trigger a live API call). The existing 15 tests now use an autouse fixture that mockstest_connectionto return private-success so they don't try to reach github.com. 4905 backend tests green.
Fixed
-
Spoolman edit-spool: editing a spool no longer mints duplicate filaments in the Spoolman catalogue (#1357 follow-up, reported by @pgladel) — After the initial #1357 close, the reporter showed that BB was still spawning new Spoolman filament rows on every subsequent edit. The previous fix taught
find_or_create_filamentto bridge the AMS-sync name shape ("Glow") with the user-edit shape ("PLA Glow"), but only on the find path — the moment the user changed any field that fed the match key (subtype/material/brand/color_hex) the lookup missed and a brand-new filament was created, the spool was re-linked to it, and the previous filament was orphaned. Repeating the loop produced the spread the reporter screenshotted (IDs 126/127/128/129 all "Amazon Basics / PLA Glow / PLA", slight color variants). Root fix is a behaviour change inPATCH /spoolman/inventory/spools/{id}: before callingfind_or_create_filament, the route now computes whether the desired metadata still matches the current linked filament and, if so, skips the lookup entirely (a no-op metadata edit — justnoteorweight_used— never touches the filament catalogue). When metadata IS changing it consults a newSpoolmanClient.is_filament_shared(filament_id, exclude_spool_id)helper: if the current filament is a singleton (only this spool points at it, archived spools included so a sibling-archive doesn't fake singleton-ness), the route PATCHes that filament in place viapatch_filament—name,material,color_hex,weight, plus avendor_idresolved viafind_or_create_vendorwhen the brand changed. Only when the filament is genuinely shared with another spool does the route fall back to the legacyfind_or_create_filamentpath, because PATCHing a shared filament would silently rewrite every sibling spool's metadata. Net effect mirrors internal-inventory behaviour (feedback_inventory_modes_parity saved this session): editing a spool updates the thing the spool already points at, instead of proliferating new entities. Three new tests intest_spoolman_inventory_api.py::TestSpoolmanInventoryCRUDcover the new contract: a no-op metadata edit (onlynote/weight_used) does NOT callfind_or_create_filamentORpatch_filament; a subtype change against a singleton filament callspatch_filament(7, {...name: "PLA Matte"})and NOTfind_or_create_filament; the same change withis_filament_sharedmocked to True falls back tofind_or_create_filamentand does NOT callpatch_filament. 162 spoolman-inventory tests + 192 broader spoolman tests green; ruff clean. -
Inventory: "Print labels…" now works in Spoolman mode — Both endpoints already exist (
POST /inventory/labelsfor the built-in table,POST /spoolman/labelsfor Spoolman), and theLabelTemplatePickerModalcorrectly branches on aspoolmanModeprop. But the modal was instantiated inInventoryPage.tsxwithspoolmanMode={false}hard-coded, with a stale comment from the original PR claiming "Spoolman path hands users an iframe straight to Spoolman so the per-spool button never shows in that context". That assumption stopped being true when the unified inventory UI shipped — the per-spool button DOES show in Spoolman mode now, but every click resolved to/inventory/labelswith Spoolman spool IDs and returned404 Spool(s) not found. Fix passes the actualspoolmanModevalue through to the modal (one-line change, plus removing the stale comment block). The existingLabelTemplatePickerModal.test.tsxalready covers both branches at the component level — the gap was that no test exercised the InventoryPage wiring. This is another instance of the parity rule from [#1390 follow-up]: inventory features must ship the same UX in both modes; per the new feedback memory, any future inventory change gets a mental checklist of both routes + both client methods + both UI gates before being considered shipped.
Added
- Inventory: "Reset usage to 0" also works in Spoolman mode (#1390 follow-up) — The first cut of this action only wired the built-in inventory path, so Spoolman users saw the eraser icon disappear when they switched modes. Now the same two endpoints exist on the Spoolman inventory router:
POST /spoolman/inventory/spools/{spool_id}/reset-usagePATCHes Spoolman's/spool/{id}withused_weight: 0for a single spool,POST /spoolman/inventory/spools/reset-usage-bulkdoes the same per ID across an explicit list and returns{reset: N}(individual Spoolman failures are logged and counted out, the batch keeps going). Areset_spool_usage(spool_id)helper onSpoolmanClientis the actual HTTP call. The mutations inInventoryPage.tsxalready had the right shape — they now switch onspoolmanModeto pickapi.resetSpoolmanInventorySpoolUsage/api.bulkResetSpoolmanInventorySpoolUsagevs the internal-inventory client methods, and the threespoolmanMode ? undefined : ...gates that hid the eraser buttons in Spoolman mode are gone. Three new tests intest_spoolman_inventory_api.pylock the Spoolman path (per-spool, bulk, and the typo-wipe guard on empty list). The wiki page now says "Spoolman users get the same actions" instead of the original "Spoolman-mode users don't see either button" note. 4900 backend tests green. - Inventory: "Reset usage to 0" per spool and across all active spools (#1390 follow-up, requested by @IndividualGhost1905) — Each spool's
weight_usedcounter accumulates over its lifetime and feeds the "Total Consumed (Since tracking started)" stat on the Inventory page. There was no way to clear it without nuking the spool or manually editing the field — and manually settingweight_used=0via PATCH /spools/{id} auto-locks the spool (weight_locked=trueis auto-set wheneverweight_usedis sent explicitly, so AMS auto-sync stops touching the spool), which is the wrong behaviour for "clean-slate my Total Consumed stat so future prints track from zero". Two dedicated endpoints inbackend/app/api/routes/inventory.pyzero the counter without touching the lock flag:POST /inventory/spools/{spool_id}/reset-usage(single spool) returns the updatedSpoolResponse;POST /inventory/spools/reset-usage-bulk({spool_ids: [int, ...]}) returns{reset: N}. The bulk endpoint rejects empty / missingspool_ids(HTTP 400) — no wildcard / "reset-all" shortcut, since a typo there would wipe the entire inventory's tracking; the caller must explicitly pass the list. Both leaveweight_lockedalone: if the user had locked the spool, the lock stays; if it was unlocked, it stays unlocked and the next AMS sync picks up from zero. Frontend adds two affordances: a small eraser icon button on the "Total Consumed" stat card (visible only when there's actually usage to reset AND we're not in Spoolman mode) that opens a confirm modal explaining what the reset clears and that the spools / remaining weights are not changed, and an eraser icon in each table row's action column (visible only on active spools withweight_used > 0, hidden in Spoolman mode since Spoolman manages its own usage accounting). Both routes share the sameConfirmModalinfrastructure as delete/archive —confirmActionstate now covers'delete' | 'archive' | 'reset-usage' | 'reset-all-usage'. i18n: 10 new keys (resetUsage,resetUsageTooltip,resetUsageConfirm,resetAllUsage,resetAllUsageTooltip,resetAllUsageConfirm,usageReset,allUsageReset,resetUsageFailed, plusresetUsagereused as confirm button label) translated across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). Parity check holds at 4827 leaves per locale. Tests: 8 new regressions intest_spool_reset_usage.pycover per-spool reset zeroesweight_used, per-spool reset does NOT auto-lock, per-spool reset preserves an existing lock, 404 for missing spool, bulk reset zeroes only listed spools (untouched spools keep their usage — the typo-wipe guard), bulk reset rejects empty list (400), bulk reset rejects missingspool_idsfield (400), bulk reset preservesweight_lockedacross mixed locked/unlocked targets. 4897 backend + 1901 frontend tests green.
Changed
- Settings → Filament: "Spool Catalog" now shows the same UI in Spoolman mode as in internal-inventory mode — Previously, switching to Spoolman mode hijacked the Spool Catalog card and replaced it with a Spoolman filament list (Vendor — Name / Material / Weight / Spool Weight) with inline edit for name + spool_weight. Two separate concepts had been merged into one card: a Bambuddy-local spool tare catalog (the actual purpose of the card — name + weight definitions used to compute spool tare) vs a filament editor for Spoolman's
Filamententity. The filament-editor view replaced the spool tare table entirely in Spoolman mode, with no way to see or manage the spool catalog. Now the card always renders the local Spool Catalog (Add / Edit / Delete / Export / Import / Reset / bulk-delete) regardless of inventory mode. The Spoolman-filament inline editor is removed — Spoolman users edit filament name / spool_weight in Spoolman's own UI. Side effect of the rewrite: the noisyGET /api/v1/spoolman/inventory/filaments → 400 Bad Requestthat fired on the Filament settings page even when Spoolman is disabled is gone, because the component no longer issues the probe at all. Files affected:frontend/src/components/SpoolCatalogSettings.tsx(rewrite, ~750 → ~445 lines),frontend/src/components/SpoolWeightUpdateModal.tsx(deleted — only used by the removed editor), test file rewritten to match the simplified component. No backend changes —PATCH /spoolman/inventory/filaments/{id}route still exists for API consumers, just no longer wired to a UI.
Fixed
- Stats page widgets now match Quick Stats — every panel reads per-event data (#1390 follow-up, reported by @IndividualGhost1905) — After #1378 moved Quick Stats and the run aggregates to
print_log_entries, six widgets (Filament Used, Filament Cost, Filament Trends, Printer Stats By Weight / Time, By Material, Color Distribution) plus Failure Analysis still iterated the archive list. Two divergences fell out of that split. Reprints: each reprint of an archive adds a newprint_log_entriesrow but theprint_archivesrow gets overwritten in place, so event-based widgets counted N reprints while archive-based widgets counted 1. Hard-deleted archives: the foreign key isON DELETE SET NULL, so the event survives as an orphan (archive_id=NULL) — Quick Stats kept counting it, archive-iterating widgets couldn't see it. The reporter's test server (14 archives / 52 events / 29 orphans confirmed by the diagnostic query) made the split very visible. Fix swaps the data source in two places: (1)GET /archives/slim(the only frontend caller is StatsPage, so every widget that consumes thearchivesquery gets the per-event data in one step) now reads fromPrintLogEntry, LEFT JOINsPrintArchivefor the slicedprint_time_secondsestimate (null for orphans, and downstream widgets already fall back toactual_time_seconds/duration_seconds), usesPrintLogEntry.duration_secondsas the authoritative measured-time field when present (the original computed-from-started/completed_at path is kept as the fallback so legacy event rows from pre-#1378 still surface time), and returnsquantity=1per event since per-event semantics make the archive-level quantity multiplier meaningless (verified no StatsPage widget actually readsquantity—grep -n "\\.quantity" frontend/src/pages/StatsPage.tsxreturns nothing); (2)FailureAnalysisServiceswitched fromPrintArchivetoPrintLogEntryfor every aggregation (totals, by reason, by filament, by printer, by hour, recent failures, weekly trend) —project_idfiltering still resolves through the archive table (events don't carry a direct project link) but counts the matching events, not the archives. The conftestarchive_factoryalready synthesizes a matchingPrintLogEntryper archive (added when #1378 landed), so existing tests stay green; one small tweak there now syncs the synthesized event'screated_atwith the archive's so date-range filtered tests don't lose the event toserver_default=func.now(). Three new regressions intest_archives_api.py:test_slim_counts_reprints_as_separate_rows(three reprints → three slim rows → 3× filament summed correctly),test_slim_includes_orphan_events(archive deleted, event survives, slim still returns it withprint_time_seconds=null),test_failure_analysis_counts_reprints_and_orphans(a reprint of a failed archive + an orphan failed event both contribute tofailed_printsandfailures_by_reason). One existing assertion updated — thetest_slim_returns_only_expected_fieldstest was assertingquantity == 2from anarchive_factory(..., quantity=2)call, which no longer rounds-trips through the per-event endpoint; updated toquantity == 1with a comment pointing at the semantic shift. 4889 backend tests green, 31 StatsPage frontend tests green, ruff clean. - FTP upload no longer silently treats 426 "Failure reading network stream" as success (#1401, second root cause reported by @iitazz) — Looking at the support bundle from @iitazz showed every FTP upload to their P2S (firmware 01.02.00.00) ending the same way: data channel sendall completes in ~200 ms at an impossibly high "speed" (7+ MB/s for files the printer can only actually receive at ~1–2 MB/s), then voidresp returns
426 Failure reading network stream. (error_temp)from the printer, and Bambuddy proceeds —WARNING FTP STOR confirmation not received for X (proceeding): 426 ...followed immediately byINFO FTP upload complete. The print command then gets dispatched, the printer tries to parse what's actually a partial 3MF (the reporter's downloaded-from-printer 3MF was 458752 bytes — exactly7 × 65536, our FTP chunk size — for a 668025-byte source), and surfaces the "unable to parse 3mf file" error the reporter sees. Two stacked failures: a P2S firmware / TLS-data-channel quirk that severs the FTP data stream mid-transfer (separate investigation; #1401 doesn't fix that), AND the voidresp handler inbackend/app/services/bambu_ftp.pyswallowing the resulting 426 because the original comment assumed "the data was fully sent so the file is likely on the SD card" — true for socket-level timeouts where we just didn't HEAR the 226 in time (H2D needs 30+ s tolerance and we want to keep that), false for426where the printer is explicitly telling us the data stream itself was cut. Fix splits the broadexcept Exceptioninto two branches:except ftplib.Error(coverserror_reply,error_temp,error_perm,error_proto— the server responded with a failure on the control channel) logs at ERROR and re-raises, so the outerexcept (OSError, ftplib.Error)returns False and the dispatcher sees a real upload failure instead of green-lighting a print of a truncated file;except Exceptionkeeps the existing proceed-with-warning behaviour for socket timeouts so the H2D 30-second voidresp tolerance survives. Same split applied toupload_bytes()since it had the sameexcept Exception: passshape. The reporter will still hit the underlying 426 (we haven't fixed the P2S transport problem yet — that's separate), but they'll now see an upload failure surfaced honestly rather than a confusing parse error 30 seconds into the print attempt. Tests: two new regressions inTestUploadpatch_ftp.voidrespto raiseftplib.error_temp("426 ...")and assert bothupload_file()andupload_bytes()return False. 18 upload-related tests green. The earlier-this-section validation fix is unrelated and stays — it still catches genuinely raw.gcodefiles at the upload step. - Upload validation rejects unprintable 3MF / raw-gcode files at the upload step instead of letting them fail at the printer (#1401, reported by @iitazz) — Reporter sliced in OrcaSlicer, uploaded the result to Bambuddy, clicked Print, and the printer rejected with "Printing stopped because the printer was unable to parse the 3mf file" — every time, for multiple files, on both library uploads and SD-card-browsed files. Trace through the support bundle showed: (a) the stored library file ended in
.gcode(not.gcode.3mf), and (b)background_dispatch.pyconstructs the FTP destination filename by appending.3mfwhen the source doesn't already end in.gcode.3mf/.3mf— so raw gcode gets shipped to the printer namedwhatever.gcode.3mfand the firmware's 3MF parser chokes on the missing zip header. The same shape also manifests asFailed to parse plates from archive ... File is not a zip filewarnings on Bambuddy's side. Whether the user manually re-extensioned a file or their slicer saved as.gcodeinstead of.gcode.3mf, the right place to catch this is the upload, not the printer 30 seconds later. Newvalidate_print_file_upload()helper inbackend/app/api/routes/library.pyruns two checks: (1) reject any filename ending in.gcode(but not.gcode.3mf) with a clear message — "Raw .gcode files can't be printed on Bambu printers in network mode — they need a .gcode.3mf zip container (gcode plus metadata). Re-export from your slicer and make sure the file ends in '.gcode.3mf', not just '.gcode'. If your OS hides extensions, double-check the file with the extension visible." (2) For any filename ending in.3mf(incl. the compound.gcode.3mf), verify the file body starts withPK\x03\x04(ZIP magic bytes); reject otherwise with a message pointing at the slicer's "Export Plate Sliced File" action. Suffix-based check rather thanos.path.splitextbecause compound extensions like.gcode.3mfshow up as just.3mfafter splitext — both must trigger the same validation. Applied to every relevant upload route:POST /library/files(covers File Manager upload AND the printer-card drag-drop, which routes through the same endpoint),POST /archives/upload(single archive),POST /archives/upload-bulk(rejects bad files per-row instead of aborting the batch — one bad file in a 10-file drag-drop doesn't lose the other nine),POST /archives/{archive_id}/source(per-archive source 3MF),POST /archives/upload-source(slicer-post-processing match-by-name). Validation runs AFTER_resolve_upload_destinationso folder-permission rejections (403 readonly, 400 missing-path, 409 collision) still take precedence — preserves existing error ordering. STL / image / other non-print uploads bypass the validator entirely; Bambuddy is also a library, not just a print dispatcher. Frontend visibility fix inFileUploadModal.tsx(same component used by File Manager + Printers page + Archives): the modal auto-closed aftersetIsUploading(false)regardless of per-file results, so a 400 rejection from the new validator was technically captured but never shown — the modal vanished too quickly. Now (a) errors render inline as red text under the file row instead of as a hover-onlytitletooltip, and (b) the modal stays open if any file ended with status='error', so the user can read the backend's actual remediation message before clicking Close. The bulk archiveUploadModal.tsxwas already showing inline errors and not auto-closing — that one didn't need the fix. Tests: 7 new integration tests inTestPrintFileUploadValidationcover: raw.gcoderejection at the library route (asserts the error message names the remedy), non-zip.3mfrejection, non-zip.gcode.3mfrejection (compound-extension code path), happy-path valid.gcode.3mfaccepted, STL / non-print extensions still bypass,POST /archives/uploadnon-zip rejection,POST /archives/upload-bulkper-file error collection with mixed good/bad files in one request. Plus one fixture update intest_external_folders_api.py—test_upload_persists_correct_db_shapewas uploadingmodel.3mfwith placeholder bytesb"x"to exercise the DB-shape path; updated to use a minimal real zip so the new validator doesn't block the unrelated test. 4968 backend tests green, 41 FileUploadModal frontend tests green, ruff + frontend build clean.
Added
-
Inventory: Storage Location filter chip (#1400, reported by @pgladel) — Reporter manages a lot of physical filament storage locations and wanted a quick way to narrow the inventory list to "what's in shelf A" / "what's in drawer 1" without typing a search query each time. Inventory page grows a new filter chip alongside the existing Material / Brand / Category / Spool Name dropdowns. Distinct storage-location values are pulled from the spool list and rendered as options; selecting one filters the table to spools assigned to that location. An additional No location set entry appears when at least one spool has an empty
storage_location, so users can find unfiled spools the same waycategoryNoneworks for unfiled categories. The chip self-hides when no spool has a storage location set (avoids noise on fresh installs). Pattern is identical to the existing Category chip from #729 — clear-all-filters andhasActiveFiltersboth include the new state. Whitespace normalisation: distinct-value extraction and filter comparison both.trim()the field so a spool whose location was saved as"Shelf A "doesn't render as a separate dropdown option from"Shelf A". i18n: reuses the existinginventory.storageLocationlabel (already shipped for the spool-edit field — no duplication); adds a newinventory.storageLocationNonekey, translated to all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). The "Extended Solution" from the issue (dashboard widget showing locations) is not in this change — open to revisiting if there's appetite. Parity check holds at 4818 leaves per locale. 24 InventoryPage tests in the existing suite still pass. -
Smart plugs: auto-off after AMS drying completes (#1349, reported by @Kyobinoyo) — Reporter asked for the equivalent of the existing print-finish auto-off, but triggered when an AMS drying cycle ends — so the smart plug that powers the printer + AMS combo cuts power once humidity has been driven out, without the user babysitting it. Shipped as a simple per-plug pair of fields that mirrors the existing print-finish auto-off shape. Per-AMS plug routing (separate plug for the AMS only, per-AMS targeting on dual-AMS printers) was scoped out for now — Bambuddy's plug model is plug→printer, not plug→AMS, so the trigger fires whenever any AMS attached to the linked printer finishes a dry cycle. Two new SmartPlug columns with a same-migration block in
database.py(SQLite usesBOOLEAN DEFAULT 0/INTEGER DEFAULT 10; Postgres branches toDEFAULT false/IF NOT EXISTS):auto_off_after_drying BOOLEAN(defaults False so nobody opts in by accident);off_delay_after_drying_minutes INTEGER(defaults 10 — separate from the print-finish delay because the AMS chamber is hot post-cycle and users often want longer cooldown than the print-finish default of 5). Trigger is observed at the MQTT layer, not the scheduler —BambuMQTTClientnow keeps a per-AMS_previous_dry_times: dict[int, int]and, every time_handle_ams_datafinalises the merged AMS list, walks each unit looking for thedry_time > 0 → 0falling edge. When it fires, the newon_drying_complete(ams_id)callback runs, plumbed throughPrinterManager.set_drying_complete_callbackexactly the wayon_print_start/on_print_completealready are. The seed-from-zero false positive (first MQTT push reportsdry_time=0and the previous would otherwise read as 0→0) is guarded by the explicitprevious > 0check, and the per-AMS state means dual-AMS printers can finish drying on AMS 0 and AMS 1 independently without the second one missing the edge. Observing the falling edge at the MQTT layer (rather than inprint_scheduler._sync_drying_state) is deliberate: the scheduler's_drying_in_progressdict only tracks auto-drying initiated by the scheduler itself, so manually-triggered drying from the printer card would not fire there. The new path catches queue-triggered, ambient, AND manual drying identically because it observes firmware-reported state, not our own intent. Manager hook inSmartPlugManager.on_drying_complete(printer_id, db)mirrorson_print_completebut reads the drying-specific toggle, calls_schedule_delayed_offwithoff_delay_after_drying_minutes(always time-based — temperature-cooldown is meaningful for the printer hotend, not the AMS chamber, and Bambuddy doesn't track AMS chamber temperature). The HA-script guard from the print-finish path is preserved (scripts can be triggered but not turned off, so they're skipped). Frontend adds a single toggle + delay input on the Smart Plug card next to the existing "Auto Off" section: "Auto Off After Drying" and "Drying delay (minutes)". No changes to the Add Smart Plug modal beyond what the new fields require. Backend tests intest_smart_plug_manager.pycover the new shape: drying auto-off schedules with the correct per-plug delay; the toggle being off is a no-op even whenauto_off(print-finish) is on; the masterenabledflag still gates; HA script entities are skipped; printer with no linked plugs is a silent no-op.test_bambu_mqtt.pygets a newTestDryingCompleteCallbackclass covering the falling-edge firing once, the seed-from-zero non-fire guard, repeated zero-pushes after the edge not refiring, per-AMS independent tracking on dual-AMS units, and the "new cycle after completion refires" case (covers the user starting a second dry from the printer card). 4961 backend tests green; SQLite + Postgres 16 migration verified idempotent. i18n: 3 new keys (autoOffAfterDrying,autoOffAfterDryingDescription,delayAfterDryingMinutes) translated across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). Parity check holds at 4817 leaves per locale.
Changed
- Bulk and scheduled archive purge now honour the soft / hard delete choice that single-archive delete already exposes (#1390 follow-up) — Reporter IndividualGhost1905 followed up after the #1378 / #1343 backfill fix landed and pointed out the next inconsistency: the per-archive delete dialog has had a "Also remove from Quick Stats" checkbox since #1343, but the bulk "Purge Old" button and the scheduled daily auto-purge sweeper both ignored that choice and hard-deleted unconditionally. The "Purge Old" path called
archive_purge_service.purge_older_thanwhich routed throughArchiveService.delete_archivedirectly — dropped the archive row, the linked PrintLogEntry rows gotON DELETE SET NULLso they survived witharchive_id=NULL, Quick Stats kept the filament / cost / energy contribution from the orphaned log rows but the archive-list-iterating widgets (Filament Trends / Printer Stats / By Material / Color Distribution) lost the contribution and Time Accuracy lost the join target. Visibly inconsistent, and "automatically deleted from statistics without any warning" was a fair characterisation of the half that did drop. Fix is to thread the samepurge_statsparameter through every surface, defaulting to soft-delete (matches the single-archive default — files off disk, archive row hidden viadeleted_at, Quick Stats fully preserved, all archive-list widgets keep showing the row). Three surfaces affected: (1)POST /archives/purgeacceptspurge_statsin the body, defaults False (soft); the response now echoes which mode ran. (2)GET /archives/purge/previewaccepts the same flag as a query param so the count matches what a real purge would touch — soft mode excludes already-soft-deleted rows, hard mode counts them as eligible-for-promotion. (3) The auto-purgearchive_auto_purge_statssetting (default False) controls whether the daily sweeper runs in soft or hard mode; the existing_maybe_run_auto_purgereads it on every tick.ArchivePurgeRequest/ArchivePurgeSettingsschemas extended,archive_purge_service.purge_older_thanandpreview_purgetakepurge_stats=Falsekwarg, the existing single-row delete tests pass unchanged. Frontend: "Purge old archives" modal grew a checkbox below the preview ("Also remove from statistics" with a hint explaining the difference), and the Settings → Archives auto-purge card grew the matching toggle (disabled when auto-purge itself is off). Copy in the modal rewritten across all 8 locales to reflect that the default no longer "permanently removes from the database" but instead hides + removes files while keeping Quick Stats intact. Behaviour change for existing auto-purge users: the sweeper used to hard-delete by default and now soft-deletes by default. After the upgrade, existing auto-purge users will start preserving more data in Quick Stats rather than losing it — the safer direction of the two, but call it out. Users who want the old hard-delete behaviour can tick the new toggle once. 4 new integration tests intest_archive_purge_api.pypin the new contract: manual purge soft-deletes by default, manual purge hard-deletes whenpurge_stats=truebody flag is set, auto-purge soft-deletes by default, auto-purge hard-deletes when the settings opt-in. Existing throttle/disabled tests still pass. 11 tests total in the file, all green; 4951 in the full backend suite. i18n parity check clean across all 8 locales. - Cloud login: corrected the access-token hint to reflect that Bambu Lab no longer surfaces the token in any UI, and called out the China-region constraint explicitly (#1396) — Reporter wintsa123 filed that China-region users can't log into Bambuddy. The code path itself is fine: PR #1013 (April) already added the China-region selector to the login form and routes token validation to
api.bambulab.cn. The actual gap was documentation. The old in-appaccessTokenHintsaid "Paste your Bambu Lab access token (from Bambu Studio)" — but Bambu Studio never exposed the token in any UI, and the profile page onbambulab.comthat used to show it is gone. For China-region accounts the email/password flow is fundamentally unusable because those accounts are bound to phone numbers, not email — token login is the only path, and the hint didn't say so. UpdatedaccessTokenHintin all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW) to state that China accounts must use this path and point at the wiki for the MakerWorld-cookie retrieval procedure. Wiki pagefeatures/cloud-profiles.mdalso rewritten under "Access Token Login": adds a "Region: China must use token login" note, replaces the dead "from Bambu Studio" guidance with the working MakerWorld-cookie method (browser DevTools → Application → Cookies →token), keeps the Python-script alternative for global-region accounts, and flags that the cookie value is sensitive. No backend changes — the token-validation endpoint accepts bothglobalandchinaregions and routes to the right API host already.
Fixed
- Virtual Printer (queue / immediate / review modes): AMS data flickered or disappeared in BambuStudio between pushalls on P1S/A1 targets (#1387) — Reporter vmhomelab ran a Print Queue VP against a P1S, opened BambuStudio, and saw the External Spool only — no AMS. Toggling Auto-Dispatch (which triggers a VP restart) made AMS briefly appear, then it reverted to defaults. Proxy Mode worked fine. The earlier #1371 sticky-keys fix only handled one of two Bambu firmware incremental-push shapes: it preserved cached AMS when the incoming push omitted the
amskey entirely (H2D's common incremental shape). The reporter's P1S firmware (01.09.01.00) instead sends incrementals with theamskey present but the innerams.amsarray stripped —{ams_status: 1, humidity: 2}instead of{ams: [...], ams_status: 1}. To the previous sticky-keys check that read as "key present, leave new state alone," so the bridge cache got overwritten with the stripped blob; the slicer's next 1 Hz read sawamswith no unit list and fell back to the "no AMS" default render. Toggling Auto-Dispatch restarted the VP and got a fresh pushall in; the next P1S incremental stripped it again. (H2D rarely hits this — its incrementals typically don't carryamsat all, so #1371 alone was enough there. The reporter's same-VP-architecture pinging both an H2D and a P1S would observe the H2D works while the P1S doesn't, which is exactly the split that surfaced this.) Fix is a deep-merge applied to theamskey inside the bridge cache, mirroring the structure Bambuddy itself already does inbambu_mqtt.py::_handle_ams_data(which is why Bambuddy's own AMS display stays coherent on the same firmware): scalar fields likeams_statusandhumiditytake the new value, but theams.amsarray is merged unit-by-unit onid, each unit'strayarray is merged tray-by-tray onid, and units / trays the incremental doesn't mention survive intact from the cached full state. A tray-targeted incremental during a print like{ams: [{id: 0, tray: [{id: 0, state: 11}]}]}now updates that one tray's state without nuking the other three trays' tray_type/tray_color. Helper added as_merge_ams_dictinbackend/app/services/virtual_printer/mqtt_bridge.pynext to_ip_to_uint32_le, called from the existing sticky-keys block. Three new regression tests underTestPushStatusCacheinbackend/tests/unit/test_vp_mqtt_bridge.pycover the status-only partial (the reporter's exact reproduction), the multi-AMS unit-level merge, and the multi-tray merge. The existingtest_incoming_ams_update_replaces_cached_amsstill passes — fresh full updates still take effect, the merge only protects the cache from stripped incrementals. 32 tests total in that file, all green. Verified the cross-subnet topology from the report (printer / Bambuddy / slicer each on a different /24) is incidental: the symptom is the same regardless of subnet once the partial-shape arrives; the latency just makes the "empty cache when slicer first connects" race more visible. ProxyMode is unaffected because Proxy is raw byte-forwarding rather than a cached-as-base mirror — it never had this class of bug. - Quick Stats showed Filament Cost = 0 and empty Time Accuracy on pre-upgrade data after the 0.2.4.1 stats rewrite (#1390) — Reporter IndividualGhost1905 upgraded to 0.2.4.1 (which shipped the per-event aggregation rewrite from #1378) and saw the Stats page split between consistent values (Total Prints / Print Time / Filament Used / Energy / Success Rate matched the archive list) and zero-or-empty ones (Filament Cost, Time Accuracy). Inconsistency was a migration gap: #1378 added six columns to
print_log_entries—archive_id,cost,energy_kwh,energy_cost,failure_reason,created_by_id— but didn't backfill any of them. So every pre-upgrade log entry kept NULL on all six. The new Quick Stats query sumsPrintLogEntry.cost(gets 0 for legacy data); the time-accuracy query joinsPrintArchive ON archive_id(drops every legacy run from the average). Counts and per-row fields that already existed pre-#1378 (status,duration_seconds,filament_used_grams) kept working — which is why some panels looked right and others didn't. Fix is a two-step backfill inrun_migrationsnext to the existing column-add block (DML, runs insidebegin_nested()not_safe_executesince the latter is documented "DDL only"): step 1 links each orphan log entry to its archive viaprint_name + printer_id(highest archiveidwins on tiebreak — newest matches the overwrite-then-stop shape that pre-#1378 reprints left behind); step 2 copiesarchive.cost / energy_kwh / energy_costonto the latest matching log entry per archive, but only for archives where no log entry yet carries a cost. That second clause is the idempotency anchor and also the double-count guard for users running this migration after #1378 has already written cost-bearing rows for new runs — those archives are left untouched. Earlier reprints stay NULL, matching the "first/latest writes, rest stay NULL" convention #1378 introduced. Sum across the legacy reprint chain reproduces sum-of-archive-cost exactly, so the Quick Stats Filament Cost column matches the pre-upgrade total instead of dropping to zero. SQL is plain ANSI — correlated UPDATE withLIMIT 1in the SET subquery,WHERE id IN (SELECT MAX(id) ... GROUP BY archive_id HAVING SUM(CASE WHEN cost IS NOT NULL THEN 1 ELSE 0 END) = 0)— verified end-to-end on both SQLite (4 unit tests intest_print_log_backfill_migration.py) andpostgres:16-alpine + asyncpg(live container reproduction). For the other widgets the reporter listed (Printer Stats, Filament Trends, By Material, Success by Material, Color Distribution) — those still iterate the archives list on the frontend rather than calling /stats, so they read consistent pre-upgrade data and aren't part of this fix; the inconsistency the reporter saw between Quick Stats and those widgets resolves itself once the backfill brings Quick Stats in line. - Spoolman: spool "Color Name" edits silently never saved — Bambuddy was writing to a field Spoolman doesn't have (#1357) — Reporter pgladel edited a spool's Color Name in Spoolman mode, hit Save, and saw the value snap back to the subtype on the next read. Martin shipped #1319 in May to handle "form round-trips the synth value back as if it were user input" — that fix's read/form-prefill half was correct (the
color_name_is_synthesizedflag, the blank-on-synth form init), but the write half assumed Spoolman has acolor_namefield on Filament. It doesn't. Verified against the liveFilamentUpdateParametersschema on Spoolman 0.23.1:name,vendor_id,material,price,density,diameter,weight,spool_weight,article_number,comment,settings_extruder_temp,settings_bed_temp,color_hex,multi_color_hexes,multi_color_direction,external_id,extra— that's the lot. Nocolor_name. Spoolman's PATCH happily returns 200 for{"color_name": "Red"}and just silently discards the unknown key. Sofind_or_create_filamentwas either patching a void or creating filament after filament with the same field-that-doesn't-stick (which is what produced the reporter's "BB also created a bunch of new filaments" trail of duplicates on each save attempt). The fix takes the same route as the existing BambuStudio slicer-preset storage: persist color_name onspool.extra.bambu_color_nameas a JSON-encoded string, register the extra field viaensure_extra_fieldbefore write (Spoolman 400s on unknown extra keys), and read it back in_map_spoolman_spoolwith priorityspool.extra.bambu_color_name → filament.color_name (forward-compat for any future Spoolman release that adds it) → subtype synth. Also dropped the now-deadcolor_namepassing throughfind_or_create_filamentandcreate_filament— Spoolman would discard it anyway and keeping the dead pipe risked the same confusion the next time someone reads this code. The previous "match by name then patch color_name" loop is gone; what survives is the name-match resilience added earlier this turn so an AMS-sync-created filament named"Glow"still matches the user-driven edit's composed"PLA Glow", which prevents the duplicate-filament trail. The frontend form'scolor_name_is_synthesizedhandling is unchanged — that part already worked. Tests rewritten across the three affected suites (test_spoolman_inventory_methods.py,test_spoolman_inventory_helpers.py,test_spoolman_inventory_api.py) to pin the new contract: filament patch never carriescolor_name, route writes tobambu_color_nameextra, read prefers extra over filament-field over synth. Verified end-to-end against the live Spoolman instance at the reporter's setup (PATCH /filament with color_name → field absent from response; PATCH /spool with extra.bambu_color_name → field present in response). - Add Smart Plug (HA mode) — search dropdown let users pick entities the schema would reject, surfacing as a cryptic regex error on Save (#1388) — Reporter MartinNYHC opened the Add Smart Plug dialog, typed a search prefix matching a multi-entity HA device (a Shelly-style outlet exposing one
switch.*and severalsensor.*/binary_sensor.*siblings under the same friendly-name prefix), clicked one of the entities, filled in the optional power/energy sensors, and clicked Save. The backend returned 422 with the raw Pydantic messageString should match pattern '^(switch|light|input_boolean|script)\.[a-z0-9_]+$'. After the dropdown closed and the search cleared, the entity-list refetch (with no search param) returned the default-domain-filtered list — which didn't include the user's pick — soselectedEntity = haEntities.find(...)was undefined, the field rendered as visually empty (placeholder shown), buthaEntityIdstill held the bad value the user had selected. Root cause was atbackend/app/services/homeassistant.py::list_entities: when a search query was present, the function bypassed the domain filter entirely and returned matches across every HA domain — including ones theSmartPlugBase.ha_entity_idregex atbackend/app/schemas/smart_plug.py:17could never accept. Offering a clickable choice the user can't save is broken UX; the fact that the error message then saidswitch|light|input_boolean|scriptmade it look like a schema problem rather than a search-permissiveness problem. Fix: the allowed-domains filter ({"switch", "light", "input_boolean", "script"}, kept in sync with the schema regex) now always runs, and search composes on top of it as an additional substring match againstentity_idorfriendly_name. Whitespace-only search strings are treated as no search. Verified the smart-plug code path is unchanged between 0.2.4 and 0.2.4.1 — this bug was latent since the script-domain commit in February 2026 and was only noticed now because the reporter hadn't reopened the modal in months. 5 new regression tests inbackend/tests/unit/services/test_homeassistant_list_entities.pycover the no-search baseline, the search-still-domain-filters case (the actual #1388 reproduction), the entity_id-or-friendly_name substring match, case-insensitivity, and the whitespace-only edge case. - H2S with no AMS could not start a print — firmware rejected the dispatch with
07FF_8012"Failed to get AMS mapping table" (#1386) — Reporter krootstijn (H2S + no AMS) clicked Print and got an immediate firmware error. Two stacked misclassifications had quietly added H2S to the dual-nozzle code paths over time. The first was instart_print_jobatbackend/app/services/bambu_mqtt.py:3168— theis_h2dflag was set true for("H2D", "H2D PRO", "H2DPRO", "H2C", "H2S", "X2D"). That single flag controlled both the firmware bool→int format (legitimately needed for the whole H-family) and the external-spool routing branch (ext_ams_id = tray_id if is_h2d else 255) which is only correct for actual dual-nozzle printers. With no AMS, the external-spool sentinel is254; the dual-nozzle branch wroteams_id=254intoams_mapping2instead of the canonical255. The exact failure shape (07FF_8012) is even called out in the comment six lines above the bad line — H2S was getting routed straight into the path the comment warned against. The second misclassification was the use_ams=False fallback atbambu_mqtt.py:3213(if ams_mapping and use_ams and not is_h2d) — meant to skip the safety drop on dual-nozzle printers whereuse_amscontrols nozzle routing — also skipped H2S, so the firmware never got a chance to fall back to external-spool mode. A third site atbambu_mqtt.py:3987(and its sibling atbackend/app/api/routes/kprofiles.py:119) classified dual-nozzle by serial prefix("094", "20P9", "31B8B"), which is wrong because H2S shares prefix094with H2D. Fix splits the conflated flag into two:is_h_family(firmware-format gate, includes H2S) andis_dual_nozzle(routing/use_ams gate, excludes H2S; prefers the runtime_is_dual_nozzleflag set fromdevice.extruder.infoand falls back to model name for the brief window right after connect). The K-profile delete and the edit route now use the same two-source check instead of the serial prefix. Empirically verified across 9+ stored H2S support bundles (nozzle_count: 1in every one) and the reporter's bug log (07FF_8012immediately after dispatch). Four new regression tests:test_h2s_single_external_spool_uses_main_id,test_h2s_no_ams_forces_use_ams_false,test_h2s_keeps_integer_format_for_calibration_fields, plus a newtest_h2s_uses_single_nozzle_formatin the K-profile suite. The K-profile detection tests were also updated to set both model name and runtime flag rather than relying on serial prefix, since the source-of-truth has shifted.
[0.2.4.1] - 2026-05-16
Changed
-
Support bundle audited for new features — adds OIDC, 2FA, API keys, library/inventory/queue/maintenance totals, slicer-API reachability, GitHub backup status, per-printer Obico flag; also redacts two settings that were leaking and fixes a reachability-check architecture bug — The
support-info.jsonblock in support bundles auto-includes thesettingstable (with sensitive-key redaction), so settings-stored features like LDAP, Obico globals, integrated slicing URLs, Tailscale, and queue-drying already flowed through. What was missing was anything stored in dedicated tables, which had grown substantially without the bundle being updated. Triaging the recent OIDC / 2FA / group bugs (#1292, #1297) and the X1C slicer investigation involved repeatedly asking reporters for information that should have been in the bundle. New blocks added to_collect_support_infoinbackend/app/api/routes/support.py:auth— OIDC providers (cleartextname,is_enabled,scopes,email_claim,require_email_verified,auto_create_users,auto_link_existing_accounts,has_default_group,has_icon,linked_user_count;client_id/client_secret/issuer_urlstay out of the bundle), 2FA counts (users_with_totp,email_otp_codes_pending), API key counts (total/enabled/expired), long-lived token counts (total/active), group counts (system/custom).library—library_files_total,library_files_in_trash,library_folders_total,external_folders_total,external_links_total,makerworld_imports_total.inventory—spools_internal,k_profiles_internal,k_profiles_spoolman.queue—pending_total,manual_start_pending,oldest_pending_age_seconds(catches items stuck because their target printer is offline or filament doesn't match).maintenance—items_total,items_enabled.integrations.github_backup—configs_total,providers_useddict (github/gitea/forgejo/gitlab),schedule_enabled_count,last_failure_count.integrations.slicer_api—enabled,preferred,bambu_studio_url_set,orcaslicer_url_set, plus an actual 2-second HTTP reachability ping (bambu_studio_reachable,orcaslicer_reachable) to differentiate "URL empty" from "URL misconfigured" from "service down". Per-printerobico_enabledflag added to each entry inprinters[], parsed fromobico_enabled_printerssetting via a new_parse_obico_enabled_printershelper that tolerates legacy comma-separated formats. Plus three smaller but important fixes caught while testing the bundle against a real instance: (1)mqtt_brokervalue was leaking — the keyword-substring redaction filter atsupport.py:850had no entry that matched themqtt_brokersetting name, so the broker IP (e.g.192.168.255.16) was appearing in cleartext. Addedbrokertosensitive_keys. (2)virtual_printer_tailscale_auth_keywas leaking — same reason, no keyword in the filter matched_auth_key. Addedauth_keyto the keyword set, AND added a value-prefix safety net (tskey-) so any FUTURE Tailscale setting with an unexpected name still auto-redacts when its value starts with the Tailscale auth-key prefix. (3) Slicer-API reachability check was always returningnull/falseeven when the slicer was up — two root causes stacked. First, the old code passedinfo["settings"](already redacted) into_collect_slicer_api_info, so whenbambu_studio_api_urlhad been redacted to"[REDACTED]", the httpx call hit that literal string and crashed; when the setting was empty, the URL came through as""and the function returnedNone. Second — caught on the next round of testing — even after switching to read directly fromSettings.value, the check only looked at the DB row, but the real slicer routes (archives.py:3174-3180,library.py) resolve the URL with a three-level precedence: DB setting →app_settings.bambu_studio_api_url(which reads theBAMBU_STUDIO_API_URLenv var) → built-in defaulthttp://localhost:3001. Most installations run the sidecar on the default port or via env var, so the DB-only check returnednulleven when the slicer was up and reachable. The collector now mirrors the route's exact resolution path. The block now also reportsbambu_studio_url_set_in_db: boolandbambu_studio_url_source: "db" | "env_or_default" | "unset"so triage can see WHICH layer supplied the URL — separates "user explicitly configured it" from "they're using the default port" without leaking the URL itself. Two regression tests pin both layers:test_reachability_uses_unredacted_url(no"[REDACTED]"ever reaches_check_url_reachable) andtest_env_var_fallback_url_pinged_when_db_setting_empty(DB empty + env-var-set URL is actually pinged and reported reachable). All new collectors are wrapped intry/exceptso a single failure on one block can't blank the rest of the bundle. OIDC provider names are passed in cleartext deliberately — they're login-button labels (PocketID,Authentik,Google, etc.), not secrets, and provider-specific behavior (Azure handles claims differently from Authentik) is exactly the kind of detail that makes SSO bugs triagable in one round-trip instead of three. 13 new unit tests inbackend/tests/unit/test_support_helpers.pycover the obico-parser edge cases, slicer-API reachability with mocked httpx (including the "404 = reachable" decision, the un-redacted-URL regression, AND the env-var-fallback regression), auth-info OIDC-cleartext-but-no-secrets contract, the GitHub-backup provider/failure aggregation, and the newmqtt_broker/virtual_printer_tailscale_auth_key/ value-prefix-based redactions. -
Page headers unified across the app: consistent icon size, placement, and subtitle styling (PR #1272 by @EdwardChamberlain, continuation of #1060 / #1203) — Nine pages (Archives, FileManager, Inventory, Maintenance, MakerWorld, Profiles, Projects, Settings, Stats) now share one header pattern:
w-7 h-7 bambu-green iconnext to atext-2xl font-boldtitle with atext-bambu-gray mt-1subtitle underneath, matching the look that landed earlier on Print Queue and Printers. FileManager and Projects dropped their roundedbg-bambu-green/10 rounded-xl p-2.5icon tile in favor of the plain icon to match the rest. The sidebar's "Queue" nav item is renamed to "Print Queue" (and its icon switched fromCalendartoListOrdered) to match the page header it leads to. The Stats page title is renamedDashboard → Statisticsto match the sidebar nav label that's been pointing at it (the page never was the printer dashboard — Printers is — and the mismatch confused new users; closes a small but recurring source of "where's the dashboard?" support questions). All renames flow through every locale: en/de/fr/it/ja/pt-BR/zh-CN/zh-TW updated fornav.queue,stats.title, plus a newinventory.subtitlekey ("Manage your spools" + translations) used by the inventory header. Bonus on top of the stated scope:inventory.toolbar.{filters, view, actions}were untranslated English strings in fr/it/ja/pt-BR/zh-CN/zh-TW — Edward translated them properly in the same pass.StatsPage.test.tsxupdated to assert the new "Statistics" title. Build clean, all 35 page tests still pass, i18n parity holds at 4753 leaves across all 8 locales. Maintenance page subtitle keeps its red / amber / green severity color on the "X items due · Y warnings · all up to date" line — the colors carry actual at-a-glance status information, not just visual weight. -
Bambuddy now identifies honestly as itself on every outbound request to Bambu Lab / MakerWorld / Bambu Wiki — proactive alignment with Bambu Lab's 2026-05-12 statement on cloud access, which draws a clear line between modifying AGPL code (allowed) and "impersonating official clients in communication with our cloud infrastructure" (not allowed). Bambuddy was already on the right side of that line on the main authenticated cloud path (
User-Agent: Bambuddy/1.0inbambu_cloud.py:_get_headers), but three secondary call sites were sending browser User-Agents — originally added under the assumption Cloudflare's WAF would block non-browser identification. Tested on 2026-05-12 withcurl -H "User-Agent: Bambuddy/1.0"against all three:https://bambulab.com/api/sign-in/tfareturned HTTP 400 with the expected application-level{"code":5,"error":"Login failed"}JSON (no Cloudflare interstitial),https://api.bambulab.com/v1/iot-service/api/slicer/settingreturned HTTP 200 with the full 576 KB settings response,https://makerworld.com/api/v1/design-service/*returned the same response shape as a Firefox UA, andhttps://wiki.bambulab.com/*served identical HTML to a Chrome UA. The browser-impersonation was unnecessary. All four call sites now sendBambuddy/1.0 (+https://github.com/maziggy/bambuddy)consistently — the URL in parens makes the source unambiguous so Bambu can distinguish our traffic from impersonators if they ever audit it. Files:bambu_cloud.py(TOTP/TFA path no longer spoofs Chrome UA + Origin + Referer + Accept-Language headers — Origin/Referer were spoofingbambulab.comorigin, which the new comment block specifically calls out as removed),makerworld.py(Firefox UA replaced; the Referer header is kept because MakerWorld's CSRF / origin-check middleware uses it on some endpoints, which is functional, not identity-faking),firmware_check.py(Chrome UA on the public wiki scraper replaced — wiki has no special handling for our UA). Separately: the/v1/iot-service/api/slicer/settingendpoint requires aversionquery parameter in Bambu Studio's XX.YY.ZZ.WW format (the API returns HTTP 400 "field 'version' is not set" without it, and HTTP 422 "Invalid input parameters" for non-matching formats likebambuddy-1.0), but Bambu's server accepts ANY value within that format — verified the same 576 KB response withversion=99.99.99.99. The previous default"02.04.00.70"is an actual Bambu Studio release version (2.4.0.70). The default is now"1.0.0.0"(held in a new_SLICER_API_VERSIONmodule constant inbambu_cloud.pyand re-exported intoroutes/cloud.pyso the two route defaults stay in sync), which satisfies the format requirement without claiming to be a specific Bambu Studio build. Unchanged on purpose:version="2.0.0.0"parameters increate_setting/update_settingpayloads are the preset's format version (extracted fromcurrent.get("version", "2.0.0.0")for updates, line 443) — they describe the preset schema, not the client, and stay as-is. Two regression tests rewritten to lock in the new behavior:test_verify_totp_uses_honest_bambuddy_user_agent(wastest_verify_totp_includes_browser_headers— asserts UA starts withBambuddy/, assertsMozilla/Chrome/Origin/Refererare not present) andtest_sends_honest_bambuddy_user_agent(wastest_sends_browser_like_headers— same shape, plus continues to assert the deprecatedx-bbl-*Bambu-app identification headers are still gone). All 4598 backend tests pass. -
Spoolman weight tracking now uses per-print grams for all spools, matching the internal Filament Inventory (#1119, reported by @Moskito99) — Spoolman previously had two mutually-exclusive weight paths: AMS remain%×tray_weight auto-sync (default; only worked for Bambu Lab spools with valid RFID tray_weight) and per-print 3MF-grams tracking (only enabled when "Disable AMS Weight Sync" was toggled on). Non-BL spools without RFID fell through both paths — AMS auto-sync had no tray_weight to multiply, and the inventory_remaining fallback was wiped because activating Spoolman deletes the internal
spool_assignmenttable — so Spoolman never saw a weight update for them. The internal Filament Inventory has no such gap: it always uses per-print 3MF grams as the primary path with AMS-remain% delta as fallback, and it works for every spool type. Spoolman now does the same: per-print tracking runs whenever Spoolman is enabled and is the only writer ofremaining_weight. AMS auto-sync continues to maintain spool metadata and slot assignments but no longer touches weight (eliminating the double-count that would otherwise occur for BL spools with both paths active).store_print_data(spoolman_tracking.py:159) had itsdisable_weight_syncearly-return removed; the threesync_ams_traycallsites (main.py:1450auto-sync,spoolman.py:318per-printer manual,spoolman.py:517sync-all) now hard-codedisable_weight_sync=True. Thespoolman_disable_weight_syncsetting is now deprecated and a no-op — kept in the DB/UI for backwards compat. Behavioral consequence for existing users on the default flag (False): live AMS-based remaining_weight updates between prints stop happening; weight updates now arrive once per print completion with 3MF gram precision. Regression test intest_spoolman_tracking.py::test_stores_tracking_when_disable_weight_sync_is_falseproves the early-return is gone.
Added
- Manual LDAP user provisioning from the UI (#1298, reported by @Fuechslein) — Until now the only way to onboard an LDAP user was to leave
Auto-provisionon and have them log in once, because the create-user form had no LDAP awareness — admins who wanted to disable auto-provision had to hand-edit the database to create the row. The user-create modal now grows aLocal / LDAPtab toggle (visible only when LDAP is enabled in settings, so non-LDAP installs see no UI change). The LDAP tab is a directory search: type ≥2 characters and the newGET /auth/ldap/searchendpoint uses the service-account bind to query the directory with a fixed OR filter acrosssAMAccountName,uid,mail,displayName, andcn(covering both Active Directory and OpenLDAP layouts; user input is RFC-4515 escaped so a typed*doesn't enumerate the whole tree). Each result is annotated withalready_provisionedso usernames that already exist as BamBuddy users render dimmed and disabled. Picking a result and clicking Provision user hitsPOST /auth/ldap/provision, which re-resolves the username via the service bind (rather than trusting the client payload) and calls the same_provision_ldap_userhelper the auto-provision login path uses — so group mapping, default-group fallback, and email sync behave identically regardless of which path created the user. Distinct error responses cover the failure modes (400LDAP disabled / query too short,404directory miss,409username already exists locally vs. already-provisioned LDAP user,503directory unreachable with the underlying ldap3 exception class + message in the detail field so the operator can diagnose without reading backend logs). Backend refactor extracts_open_service_connection+_extract_user_infohelpers inbackend/app/services/ldap_service.pyso the newlookup_ldap_userand existingauthenticate_ldap_usershare the bind + attribute-extraction paths (POSIXmemberUid+ primarygidNumber+ case-insensitive DN dedupe stay in one place). Two ldap3 schema-check workarounds for OpenLDAP installs (caught in user testing against an OpenLDAP directory): (1) the directory-search connection is opened withcheck_names=Falsebecause ldap3's client-side filter validation rejects the AD-onlysAMAccountName/displayNamenames in the cross-schema OR filter before any packet is sent; (2) the search requestsattributes=["*"](all user attributes) rather than the explicit AD-flavoured name list, because ldap3'sbuild_attribute_selectionvalidates each named attribute against the server schema independently ofcheck_namesand only the*wildcard is in its hard-codedATTRIBUTES_EXCLUDED_FROM_CHECKexclusion list — so a list like["sAMAccountName", "uid", ...]still throwsLDAPAttributeErroron OpenLDAP. The login/lookup paths (authenticate_ldap_user,lookup_ldap_user) keepcheck_names=Trueso typos in the configureduser_filtersetting still fail loudly. New shared frontend component<LdapUserPicker>infrontend/src/components/LdapUserPicker.tsxhandles the debounced search (300 ms, min 2 chars), result list, selection, and provision mutation; it's rendered from all four create-user modal paths — basic + advanced-auth inUsersPage.tsx, basic + advanced-auth inSettingsPage.tsx(the latter being the "Add User" inside Settings → Authentication, which uses a separate modal flow from the dedicated Users page) — and the sharedCreateUserAdvancedAuthModalgains aldapEnabled+onLdapProvisionedprop pair so both pages drive the same component. i18n: 14 new keys underusers.modal.ldap*+users.modal.{localTab,ldapTab,tabsAriaLabel}+ 1 toast key infrontend/src/i18n/locales/en.ts(other 7 locales fall back to English per project convention). The wiki atfeatures/authentication.mdwas also corrected — the prior "When disabled, an admin must pre-create the user in BamBuddy" line was misleading (no UI path existed) and now describes the new search-and-provision flow. Regression tests: 14 unit tests inbackend/tests/unit/services/test_ldap_service.pycover the filter shape, wildcard escaping, username-canonical fallbacks (sAMAccountName → uid → cn), bind-failure propagation, the no-password-bind contract oflookup_ldap_user, and pin both ldap3 schema-check workarounds (check_names=Falseon the search connection +attributes=["*"]so OpenLDAP doesn't reject the request). 12 integration tests inbackend/tests/integration/test_ldap_provision.pycover auth gating, short-query rejection, LDAP-disabled rejection,already_provisionedannotation, the 4xx/5xx error matrix, and a happy-path provision that verifiesauth_source=ldap,password_hash=None, and group-mapping inheritance from the auto-provision path. 5 frontend tests inLdapUserPicker.test.tsxcover the debounce, the search → select → provision flow, already-provisioned rows rendering disabled, and surfaced provision errors. 65 LDAP-related backend tests + 5 picker tests pass; full backend ruff clean; frontend build clean. - Slice modal: pick the build plate (#1337, reported by @digitalskies) — Slicing a plain STL through the integrated slicer always defaulted to whatever
curr_bed_typelived in the chosen process preset (typicallyCool Plate), which the slicer CLI then rejected for high-temp filaments withPlate 1: Cool Plate does not support filament 1. The user had no way to switch plates short of cloning the process preset in BambuStudio, which defeats the point of the in-app slicer. The Slice modal now exposes aBuild platedropdown with the six canonical BambuStudio / OrcaSlicer plates (Cool Plate, Cool Plate SuperTack, Engineering Plate, High Temp Plate, Textured PEI Plate, Smooth PEI Plate) plus an explicitAuto (use process preset)option that preserves the previous behavior. The dropdown sits between Process profile and Filament rows so it stays visible regardless of how many filament slots the picked plate uses (a long filament list would otherwise push it off the modal'smax-h-[85vh]scroll viewport) and is always enabled — including when the user picks a Printer Preset Bundle from the top BundlePicker. When the user picks a specific plate, the newbed_typefield onSliceRequest(backend/app/schemas/slicer.py) flows through the dispatcher via two paths: (1) resolved-preset path — the route helper_patch_process_bed_typeinbackend/app/api/routes/library.pyoverwritescurr_bed_typeon the resolved process JSON before forwarding to the sidecar (no preset cloning required); (2) bundle dispatch path —slice_with_bundleinbackend/app/services/slicer_api.pyadds abedTypeform field to the sidecar multipart so the sidecar can pass--curr_bed_typethrough to the CLI, which lets the override take effect even though Bambuddy can't patch the bundle's process JSON locally (the sidecar materialises it from the stored .bbscfg). Sidecar versions that don't recognise the field silently no-op — the slice still runs, just with the bundle's default plate; the slicer-API fork at maziggy/orca-slicer-api will need the matching change for the bundle path to take full effect. i18n parity: 8 new keys (slice.bedType.{label,auto,coolPlate,coolPlateSuperTack,engineering,highTemp,texturedPEI,smoothPEI}) added to all 8 locales — full German translation, English fallbacks elsewhere per project convention. Regression tests: 4 intest_slice_request_bed_type.py(bed_typedefaults to None, accepts the six canonical strings, rejects overlong input via the schema'smax_length=64;_patch_process_bed_typeoverwrites an existing value, adds the field when missing, and returns the input unchanged for malformed JSON or non-dict roots), 4 intest_library_slice_api.py(resolved-preset path: withbed_typeset, the sidecar receives"curr_bed_type": "Textured PEI Plate"in the presetProfile multipart part; without it,curr_bed_typestays out of the body entirely. bundle dispatch path:bedTypeform field carries the override through to the sidecar; omittingbed_typekeeps the form field out of the request so the bundle's owncurr_bed_typeis preserved), 2 inSliceModal.test.tsx(dropdown selection putsbed_typeon the request; leaving it on Auto omits the field). 59 backend slice tests + 34 SliceModal tests pass; build and i18n parity script clean.
Fixed
-
In-app updater no longer fails with "Failed to fetch updates" when a tag on the remote was re-pointed — Symptom on native installs upgrading from 0.2.4: clicking Settings → System → Updates → Apply Update aborted with
Git fetch failed: From https://github.com/maziggy/bambuddy ... ! [rejected] v0.2.1 -> v0.2.1 (would clobber existing tag)even thoughorigin/mainand the target release tag fetched cleanly._perform_updateinbackend/app/api/routes/updates.py:526rangit fetch --prune --tags origin, which returns a non-zero exit if even one local tag would be overwritten by a moved upstream tag — and any non-zero exit was surfaced to the user as a hard failure, leaving them stuck on the previous release. Fix: added--forceto the fetch invocation so a re-pointed tag on the remote overwrites the local stale copy cleanly; matches the in-app updater's contract ("sync me to the remote"). The nativeupdate.shdoesn't hit this bug because it fetches without--tagsat all, but the in-app path can't drop--tags— release-tag refs (v0.2.4b1,v0.2.4.1, …) need to be locally resolvable for the subsequentgit reset --hard. Note for users on 0.2.4 upgrading to 0.2.4.1: the fix is in 0.2.4.1, which 0.2.4's updater can't reach. Use the CLI path documented in the release notes for this one upgrade; from 0.2.4.1 onward the in-app button works again. Docker installs are unaffected — they don't go through git. Regression test inbackend/tests/integration/test_updates_api.pyasserts--forceis in the fetch args alongside the existing--tagsassertion so a future refactor can't quietly drop it. -
Print Queue page no longer 404-storms thumbnail / plates / plate-thumbnail when an item points at a soft-deleted archive, and pending queue items for a soft-deleted archive are now cancelled with a clear reason instead of silently stuck-pending forever (#1348 follow-up) — Symptom: opening the Queue page or any of its sub-views fired
GET /archives/{id}/thumbnail,GET /archives/{id}/plates, andGET /archives/{id}/plate-thumbnail/{n}for queue rows pointing at archives that had been soft-deleted (#1343 leaves the row but removes files from disk), all returning 404. Frontend'sonErrorhandler hid the broken<img>so it was visually clean, but the network tab and any pending-print logic still saw three 404s per affected row. Two underlying problems wearing one mask: cosmetic 404 storm, AND functional — a queue item whose 3MF was removed from disk can never actually dispatch, so it sits inpendingforever with no clue to the user about why. Two-part fix in the same shape as the print-log followup above: (1) New helper_cancel_pending_queue_items(db, archive_id)inbackend/app/services/archive.pysetsstatus='cancelled'+waiting_reason='Source archive deleted'on every pending queue item linked to the archive; called fromsoft_delete_archivealongside the existing_null_print_log_thumbnail_pathscleanup. Onlypendingis touched —printingis a rare race that the printer-side fail-path catches anyway, and completed / failed / cancelled rows are historical audit-trail. Hard-delete is already covered byON DELETE CASCADEonprint_queue.archive_id. (2) Queue API serializer inbackend/app/api/routes/print_queue.py:224now checksitem.archive.deleted_atbefore populating any archive-derived field — when soft-deleted, the whole block (archive_name,archive_thumbnail,print_time_seconds,filament_used_grams, plate-specific re-reads, …) is skipped and the newarchive_deleted: bool = Trueflag onPrintQueueItemResponsesignals the soft-deleted state. Thearchive_thumbnailsuppression alone covers the thumbnail render inQueuePage.tsx:434,CompactHistoryRow.tsx:45, andQueueTimelineView.tsx:72because they all gate on it. The/platesquery atQueuePage.tsx:329was gated onarchive_idonly —archive_idis the real FK and stays exposed in the response (the queue scheduler still needs it for audit / dispatch checks), so the query was added a new&& !item.archive_deletedclause to respect the new flag. Regression tests inbackend/tests/integration/test_print_queue_api.py:test_soft_delete_archive_cancels_pending_queue_itemspins the cancel-only-pending behavior (completed rows untouched),test_queue_api_hides_archive_surface_when_soft_deletedpins the suppression +archive_deleted=Truefor soft-deleted archives,test_queue_api_still_exposes_archive_surface_when_livepins the sanity guard that live archives' fields keep flowing through. All 3 new tests + 146 in the queue/archives/obico sweep pass; ruff clean. -
Print log no longer 404-storms on the thumbnail endpoint for entries whose archive was deleted or whose print failed before a thumbnail was extracted (#1348 follow-up) — Symptom in DevTools when opening Archives → Print Log: a 404 per orphaned entry per render. Visually clean (the
<img onError>handler infrontend/src/pages/ArchivesPage.tsx:3763hides the broken image), but noisy and wasteful. Root cause inbackend/app/api/routes/print_log.py:91-113:PrintLogEntry.thumbnail_pathis copied by value fromarchive.thumbnail_pathat write-time (main.py:3615) but the FK onarchive_idisON DELETE SET NULL(#1378) — so log entries survive archive deletion to preserve stats history, but the cached thumbnail_path string keeps pointing at a file that was removed when the archive's directory was deleted. Same shape for failed prints that recorded an expected thumbnail path before the extractor wrote (or skipped writing) the file. Two-part fix: (1)get_print_log_thumbnailself-heals when the file is missing — it NULLsthumbnail_pathon the entry and commits before returning 404, so the frontend'sentry.thumbnail_path && <img>gate keeps the next page render from re-requesting. (2) New helper_null_print_log_thumbnail_paths(db, archive_id)inbackend/app/services/archive.pyis called fromsoft_delete_archiveanddelete_archivebefore the on-disk files are removed — eager clear so future deletes don't cause the one-time storm at all. The route handler covers stragglers (failed prints, files manually moved, etc.). Regression tests inbackend/tests/integration/test_archives_api.py:test_soft_delete_clears_thumbnail_path_on_linked_log_entriespins the eager clear on the soft-delete route,test_hard_delete_clears_thumbnail_path_before_fk_cascadepins it onArchiveService.delete_archive(used by the auto-purge sweeper),test_print_log_thumbnail_route_lazy_nulls_missing_filepins the route's self-heal for failed-print orphans where the file was never written. All 4 new tests + 61 in the archives/print-log sweep pass; ruff clean. -
Camera stream no longer freezes every ~30 s when Obico fault detection is enabled on the same printer the user is viewing (#1348, reported by @SL666) — Symptom on an X1-class printer running firmware that allows only one concurrent camera connection: opening the live camera worked initially, then the stream hung within seconds and "cancelled" — clicking the in-UI refresh restored it for another few seconds before it hung again, ad infinitum. Disabling Obico fault detection made the stream stable. Root cause in
_capture_frameatbackend/app/services/obico_detection.py:209-220: the buffer-reuse path that was supposed to make Obico polling free (#1271) only worked when_last_frames[printer_id]was populated. In every race window where_active_streamshad a registered entry but the JPEG buffer was empty — stream startup before the first frame lands (1–3 s on RTSP), or any moment the upstream ffmpeg was mid-reconnect after a 30 s read timeout —try_get_active_buffered_frame(printer_id)returnedNoneand the caller fell through tocapture_camera_frame_bytes(...), which spawned its own ffmpeg + TLS proxy and opened a competing RTSP socket on the printer. On firmwares that only allow one camera connection, that second socket forced the printer to drop the live fan-out connection — viewers' ffmpeg hit its 30 s read timeout, looped through 30 reconnect attempts at 0.2 s each (all racing the next Obico poll 10 s later), exhausted retries, and the broadcaster pump exited. The user's viewer disconnected with no obvious cause in the log because the Obico capture itself looked successful (Successfully captured camera frame bytes: 83484 bytes). Fix: the buffer-reuse gate was widened from "do we have a frame in the buffer?" to "is any fan-out stream registered for this printer?" — even when the buffer is momentarily empty. New helperis_stream_active(printer_id) -> boolatbackend/app/api/routes/camera.py:82checks_active_streams/_active_chamber_streamsindependently of buffer state._capture_framenow consultsis_stream_activefirst: if True, it returns the buffered frame when available orNone(skip this poll cycle) when not — it never opens a competing socket while a viewer is attached, regardless of buffer state. The poll loop retries 10 s later, by which time the buffer is virtually guaranteed to be populated. The fresh-socket path still fires unchanged when no viewer is connected (Obico's primary use case: detection on idle/headless prints). Cost of the fix: at most one missed Obico detection cycle per viewer-attach (~10 s lag); benefit: zero competing-socket events while any viewer is connected.try_get_active_buffered_framewas refactored to delegate tois_stream_activeso the two helpers stay in lockstep — its/camera/snapshotcaller atcamera.py:859is unchanged behaviorally (snapshot is user-initiated single-shot; falling through to a fresh capture if buffer is momentarily empty is the desired behavior there). Regression tests inbackend/tests/unit/test_obico_detection.py: newtest_skips_poll_when_stream_active_but_buffer_emptyreproduces the exact race (viewer registered, buffer empty) and pins that_capture_framereturnsNoneandcapture_camera_frame_bytesis NOT called; existingtest_returns_buffered_frame_when_stream_activeandtest_falls_back_to_fresh_capture_when_no_streamwere updated to patch the newis_stream_activehelper alongsidetry_get_active_buffered_frame. All 31 obico tests + 113 in the wider camera/obico sweep pass; ruff clean. -
Reprints (including failed and cancelled ones) no longer overwrite the source archive's statistics; Quick Stats now adds per-print events (#1378, reported by @IndividualGhost1905) — Symptom: after reprinting a model from the Archive page, the reprint contributed nothing to Quick Stats / Statistics; worse, if the reprint failed at 10g while the original print used 100g, the archive card and the totals both flipped from 100g to 10g — losing the original print's data. Root cause in
backend/app/main.py:1973(_handle_print_start): every reprint shared the source archive's row via theregister_expected_print/expected_archive_idpath, and statistics (GET /archives/statsinbackend/app/api/routes/archives.py, Prometheus/metricsinbackend/app/api/routes/metrics.py) summed PrintArchive columns — so a single archive row was the only contribution per file regardless of how many times the user pressed Reprint. The cost overwrite atusage_tracker.py:633and energy overwrite atmain.py:3625compounded it: each run's actuals replaced the previous run's values on the source row. Architectural fix — statistics are now event-based, not file-based. The existingPrintLogEntrytable (one row per print event, written at print completion; lives inbackend/app/models/print_log.pyand already powered the cross-archive /print-log page) gains six columns:archive_id(nullable FK,ON DELETE SET NULLso log entries survive archive deletion — preserving the #1343 soft-delete-vs-stats decoupling),cost,energy_kwh,energy_cost,failure_reason,created_by_id. Idempotent SQLite + Postgres migrations inbackend/app/core/database.py./archives/statsand/metricsnow sum/count fromPrintLogEntryjoined toPrintArchivefor time-accuracy comparisons and user-scope filters. The_run_reprint_archiveflow still keys to the source archive (so the archive list stays one-card-per-file rather than ballooning into one card per print), but every print completion writes a newPrintLogEntryrow with the run's actual filament / time / cost / energy / status / failure_reason. The cost overwrite inusage_tracker.pynow only fires on the first run (counts existingPrintLogEntryrows for the archive); the energy background task atmain.py:3625similarly preserves the source archive's energy_kwh on reprints while backfilling the run's energy on the matchingPrintLogEntryrow (it runs afterwrite_log_entry, so it fetches and updates the latest log row for the archive). New UX surface — archive list responses gain four aggregate fields (run_count,last_run_at,total_filament_actual_grams,successful_run_count,failed_run_count) computed via a single batch query (_load_run_aggregatesinbackend/app/api/routes/archives.py); the archive card renders an orange "N prints" badge whenrun_count > 1, with a tooltip breaking down successful vs failed runs. New endpointGET /archives/{archive_id}/runsreturns every PrintLogEntry for the archive (newest first), powering a new Print Log section at the top of the Edit Archive modal — date / status / duration / filament / cost columns plus failure_reason text under failed runs (new componentfrontend/src/components/PrintLogTable.tsx). i18n keys added underarchives.card.runsBadge*andarchives.runLog.*(en/de/ja translated; other 5 locales fall back to English per project convention). Soft-delete contract preserved — thepurge_stats=truehard-delete path now alsoDELETEs linked PrintLogEntry rows so the archive's contribution truly disappears from totals; soft-delete (the default) leaves the log entries intact so #1343 stats-preservation still works. Partial-print accuracy — failed / cancelled / stopped reprints would have over-counted in the new stats path if PrintLogEntry just recorded the source archive's slicer estimate (100g for a print that stopped at 10g). The write_log_entry call site now uses a partial-aware filament value via the new_compute_run_filament_gramshelper atbackend/app/main.py: completed prints record the estimate (since the print finished), and partial prints prefer sum of tracked spool deltas fromusage_resultsif inventory was set up, falling back toestimate × progress%from the MQTT push, and finally to None if no signal exists. The per-run cost write uses the same precedence: prefer the usage_results sum (raw — without the topup-to-estimate inflation thatusage_tracker.update_archive_usageapplies for archive.cost, which assumes the print completed), and only fall back to archive.cost for completed prints. Test fixture update — thearchive_factoryconftest fixture now synthesizes onePrintLogEntryper completed test archive (since stats moved to that table, the previous "create archive only" pattern would silently produce 0-stat tests); passwith_run=Falseto skip for the "archived but never printed" case. 3 integration tests inbackend/tests/unit/test_archive_run_aggregation.pypin the reporter's exact scenario (100g completed + 10g failed reprint → stats show 110g total / 2 prints / 1 successful / 1 failed), the archive-list aggregates wiring, and the/runsendpoint ordering. 14 unit tests inbackend/tests/unit/test_run_filament_helper.pylock the partial-print math across completed / failed / cancelled / stopped statuses, both inventory-tracked and untracked paths, multi-filament sums, the >100% progress clamp, and the None-fallback cases. The full backend test suite — 4921 tests across unit + integration — passes; ruff clean; frontend build clean. -
Matplotlib no longer logs
Permission denied: /app/.configon every container start — Matplotlib (imported lazily by the STL thumbnail generator inbackend/app/services/stl_thumbnail.pywhen a user uploads an.stlfile to the library) tries to create its font/style cache at$HOME/.config/matplotlibon first import.HOMEis pinned to/appin the Dockerfile so containers withpwd.getpwuid()failures (PUID-mapped uids without a local passwd entry) still have a writable home — but/appitself is root-owned and not writable by the PUID:PGID the entrypoint drops to, so the first STL upload after a container start logged anEPERMwarning and matplotlib fell back to a fresh/tmp/matplotlib-*dir. Functionally harmless (thumbnails still rendered) but it cluttered every support bundle and forced matplotlib to re-scan system fonts on every restart (~1-2 s per first-STL-upload). Fix: addedENV MPLCONFIGDIR=/tmp/matplotlibto the Dockerfile so matplotlib uses a guaranteed-writable cache dir up front./tmpis writable by any uid so this works regardless of PUID, and the cache survives the container's lifetime so the font scan only pays its cost once per container. -
BambuStudio now sees AMS / vt_tray / net info from a virtual printer without requiring a printer power-cycle (#1371, reported by @Andlar94) — Symptom on a non-proxy VP (the user's A1 in
print_queuemode): connecting BambuStudio to the VP showed no AMS / external spool info on the Device page; the only workaround was to power the printer off and back on while BambuStudio was open, after which the info populated for one window. Root cause inMQTTBridge._on_printer_rawatbackend/app/services/virtual_printer/mqtt_bridge.py: the bridge's cache of the real printer'spush_statuswasself._latest_print_state = copy.deepcopy(print_data)— a wholesale replacement on every incoming push. Bambu firmware sends two shapes ofpush_status: full pushall responses (onpushallrequest / printer reconnect) include AMS / vt_tray / net.info / lights_report, and ~1 Hz incremental updates with just the fields that changed (temperatures, fan speeds, wifi signal). The first incremental push after a pushall therefore wiped AMS info from the bridge cache, and BambuStudio (which reads the cache via the VP's own 1 Hz status push) saw a stripped-down state with no AMS visible until the next pushall — typically only on a manual printer power-cycle, which forces Bambuddy to reconnect and re-issuepushall. Fix: in the cache update path, preserve a small set of "slicer-visible sticky" top-level keys from the previous cache when the incoming push doesn't include them:ams,vt_tray,ams_extruder_map,mapping,net,ipcam,lights_report. Mirrors the same preservation pattern Bambuddy itself already uses for its own internalstate.raw_dataatbambu_mqtt.py:2686-2711; without that, even Bambuddy's own UI would have shown blank AMS in the same way after an incremental push. The new sticky-key set adds three entries (net,ipcam,lights_report) that the slicer specifically cares about: BambuStudio readsnet.info[*].ipfor the FTP destination IP (which the bridge then rewrites to the VP bind IP), usesipcam.rtsp_urlfor the camera mirror, and renderslights_reportfor the chamber-light toggle. Note: the fix only covers the typical "incremental push omits the sticky key" case — if a future firmware sends a partial AMS list (e.g. only one unit's tray subset), that incoming partial push would still replace the cached AMS. That's a rarer scenario and would need full Bambuddy-style per-unit deep-merge; deferred until anyone hits it. Regression tests inbackend/tests/unit/test_vp_mqtt_bridge.py: newtest_incremental_push_preserves_ams_from_previous_cacheseeds the cache with a full pushall payload (AMS + vt_tray + lights_report), fires a temps-only incremental push, and pins that all three sticky fields survive with their original values; newtest_incoming_ams_update_replaces_cached_amspins the counterpart — when an incoming push DOES includeams, the cached value is replaced, so the preservation doesn't shadow real AMS state changes. All 29 mqtt_bridge tests + 158 in the wider virtual-printer sweep pass; ruff clean. -
Queue items no longer get permanently stuck in
printingstatus when the printer was inFINISHstate at dispatch time, and direct-dispatch (Library → Print) no longer reports false success in the same scenario (#1370, reported by @Martinnygaard) — Symptom: queue page showsBusy: <printer>even though the printer is connected, idle, andawaiting_plate_clear=False; no new prints will dispatch to it until the user manually deletes or reassigns the queue row. Reproducible by queueing (or directly dispatching) onto a printer that still has the un-dismissed "Print complete" prompt from a prior job. Root cause in_watchdog_print_startatbackend/app/services/print_scheduler.pyand the parallel_verify_print_responseatbackend/app/services/background_dispatch.py: the post-dispatch verifiers both treated anygcode_statetransition away frompre_stateas proof that the printer had accepted theproject_filecommand. In the reporter's bundle, item 6 dispatched while printer 3 was inFINISH(residual from item 3 earlier that day) — firmware silently rejected the newproject_filebecause the previous-print prompt was still up, and ~2 minutes later the user manually dismissed the screen prompt, putting the printer intoIDLE. The watchdog sawstate != pre_stateand returned early as "command landed", butFINISH → IDLEis the user dismissing a prompt, not the printer accepting our project_file — so the queue row stayed at'printing'indefinitely and the scheduler's busy-printer seed (SELECT printer_id FROM print_queue WHERE status='printing'inprint_scheduler.py:166-171) permanently marked printer 3 as busy. The same broad-transition bug existed in_verify_print_response, which would have caused direct-dispatch (Library → Print) onto a FINISH-state printer to report false success — silently failing to print while the UI showed the dispatch as complete. Fix: in both verifiers, narrow the "command landed" check to an allow-list of active-print states (PREPARE/SLICING/RUNNING/PAUSE) instead of "any state that isn'tpre_state". Inactive states (IDLE,FINISH,FAILED) no longer short-circuit the early return. Thesubtask_id-advance signal stays as-is in both verifiers — it remains the definitive "command landed" path for H2D firmware that sits atFINISHfor ~50 s after acceptingproject_filebefore transitioning toPREPARE(#1078 stays green in both). Resilience hardening alongside the fix: the watchdog's revert commit andprinter_manager._persist_awaiting_plate_clearnow run throughrun_with_retry(backend/app/core/database.py), so SQLite single-writerdatabase is lockedcontention can't silently drop the queue-row revert or the plate-clear gate flag. The revert path returns a tristate sentinel ("reverted"/"already_moved_on"/"revert_failed") so the post-revert MQTT session-recovery logic only runs when we actually reverted (or the commit failed) — never whenon_print_completehad already cleared the row, where a forced reconnect could break a healthy concurrent print on the same printer. (Most other queue/archive writes already went throughrun_with_retry; these two were the holdouts that surfaced as repeatedFailed to persist awaiting_plate_clearwarnings in the reporter's bundle.) Manual recovery for users on 0.2.4 who already have stuck rows: stop Bambuddy, thensqlite3 /app/data/bambuddy.db "UPDATE print_queue SET status='cancelled', completed_at=datetime('now') WHERE status='printing';"and restart. Regression tests — newtest_reverts_on_finish_to_idle_user_dismissed_prompt(queue) andtest_returns_false_on_finish_to_idle_user_dismissed_prompt(direct-dispatch) reproduce the exact reporter scenario on both code paths; newtest_does_not_revert_on_pickup_via_active_state(queue) andtest_returns_true_on_each_active_print_state(direct-dispatch) iterate all four active-print states (PREPARE/SLICING/RUNNING/PAUSE) and pin that each one is correctly treated as a valid "command landed" signal. Existingtest_no_revert_if_item_already_completedwas also hardened — it now uses a real client mock and assertsforce_reconnect_stale_session.assert_not_called(), so the tristate-sentinel guard around the recovery path is pinned (catches the regression I introduced and then fixed during the audit pass). The pre-existingtest_exits_on_state_change(usesRUNNING) andtest_exits_on_subtask_id_change_even_if_state_still_finish(the #1078 H2D path) both still pass without modification. All 29 watchdog tests across both files + 411 in the scheduler/queue/dispatch/printer-manager sweep + 3161 in the full backend unit suite + 302 in the targeted integration sweep all pass; ruff clean. -
Spool removal from AMS no longer requires a manual Reconnect on X1C printers that report
power_on_flag=Falsewhile idle (#1365, reported by @an3k via @maziggy) — On an X1C running firmware 01.08.02.00, pulling a spool out of an AMS slot left the slot showing as full in Bambuddy until the user clicked "Reconnect"; even re-reading the (now empty) slot's RFID from the printer screen didn't propagate. Root cause: the empty-slot detection in_handle_ams_dataatbambu_mqtt.py:1721gated onif tray_exist_bits_str and power_on:— meaning any MQTT message withpower_on_flag=Falsewas skipped wholesale. That guard was added in488f6631to fix #765, where a printer's final shutdown message (all-zerotray_exist_bits+power_on_flag=False) was wiping AMS slot data and triggering auto-unlink. But this user's X1C firmware emitspower_on_flag=Falsebetween prints withtray_exist_bitsstill reflecting the real slot inventory — so every spool-removal update was silently discarded and only the manual Reconnect (which sendspushall, a full per-tray snapshot independent of the bitfield path) would correct the view. Fix: narrow the skip to the exact shutdown pattern — zero bits ANDpower_on_flag=False. Non-zerotray_exist_bitswithpower_on_flag=Falseis valid idle-AMS state and the update is now applied. The original #765 regression test (test_shutdown_message_preserves_ams_data) usestray_exist_bits='0'and therefore still passes, so the shutdown protection is preserved exactly. New regression testtest_idle_printer_with_power_off_and_nonzero_bits_clears_removed_slotinbackend/tests/unit/services/test_bambu_mqtt.pypins the #1365 behavior: a removal update with non-zero bits andpower_on_flag=Falseclears the affected slot, with other slots untouched. All 250 bambu_mqtt tests pass; ruff clean. -
Discord notification provider now accepts legacy
discordapp.comwebhook URLs (#1363, reported by @mrfoureyed) — Discord's "Copy Webhook URL" button emitshttps://discordapp.com/api/webhooks/...while Bambuddy's validation inbackend/app/services/notification_service.pyonly acceptedhttps://discord.com/api/webhooks/..., raising "Invalid Discord webhook URL" on paste. Both hostnames are operational on Discord's side and serve the same webhooks. The validation now accepts either prefix; the check itself is retained (vs. removing it as suggested) because it still catches the common paste-the-wrong-thing-into-the-Discord-field error. Regression tests inbackend/tests/unit/services/test_notification_service.py— newTestDiscordProviderclass pins both hostnames accepted, non-Discord hosts rejected, empty URL rejected. -
Multi-color print archives reported near-zero cost (e.g. $0.01 for 110g) when only some AMS trays were mapped to inventory spools (#1344, reported by @nicktags) — On an H2C running multi-color prints from Bambuddy with the global default filament cost set to $10/kg, the reporter's 110.3g archive showed $0.01 instead of ~$1.10. Root cause:
archive.costwas set in two stages — first inbackend/app/services/archive.py:1100-1114at archive creation (total grams × primary filamentcost_per_kg, which produced the correct ~$1.10), then overwritten inbackend/app/services/usage_tracker.py:618-621withsum(r.cost for r in results)whereresultsonly contains AMS trays mapped to a spool in Bambuddy's inventory. On a multi-color print where 3 of 4 used trays had no inventory spool, the sum only included the one tracked slot's tiny share, e.g. 1g × $10/kg = $0.01. The overwrite logic (#505, Feb 2026) was correct for fully-tracked single-color prints but silently corrupted multi-color archives when inventory was incomplete. The multi-color slicer feature that shipped in 0.2.4 (988c0055) made this state common — many more users started running multi-filament prints from Bambuddy without first setting up inventory entries for every tray. Fix: the overwrite block inusage_tracker.pynow charges any filament weight not covered by an inventory spool at the global default rate. New computation:total_cost = sum(tracked_costs) + (archive.filament_used_grams - sum(tracked_weights)) × default_filament_cost / 1000. Fully-tracked prints are unchanged (untracked grams = 0, top-up = 0). Partial-tracked prints get the missing slots' grams charged at the default rate, so the archive reflects the whole print. Same correction applied to the manual rescan path inbackend/app/api/routes/archives.py(update_metadataandrecalculate-costsboth now readSUM(SpoolUsageHistory.weight_used)alongsideSUM(cost)and top up by the untracked delta). The pre-#1344archive.cost not overwritten with zeroregression test stays green — whentotal_costafter top-up is still 0 (no inventory match, no default rate set), the pre-existing catalog-based cost is preserved. Regression tests inbackend/tests/unit/test_cost_tracking.py:test_archive_cost_includes_untracked_filament_at_default_rate— 110g archive, only 10g tracked by inventory at $10/kg, default rate $10/kg → archive.cost = $1.10 (was $0.01 pre-fix; this is the exact reporter scenario).test_archive_cost_fully_tracked_unchanged_by_topup— when tracked weight ≥ archive grams, no top-up is applied and cost is unchanged from the pre-fix sum. All 14 cost-tracking tests + 182 in the wider usage-tracker / archives / cost-statistics suites pass; ruff clean. -
Plate-detection calibration captured the wrong camera when an external camera was configured (#1359, reported by @Andlar94) — On the reporter's A1 with an external RTSP / go2rtc camera enabled, every print start raised "Build plate not empty" no matter how perfectly they calibrated. Root cause: the runtime auto-check at print start in
backend/app/main.py:1819calledcheck_plate_empty(..., use_external=printer.external_camera_enabled, ...)— honouring the external camera setting. The manual UI check + calibration routes inbackend/app/api/routes/camera.pydeclareduse_external: bool = False, and the frontend client atfrontend/src/api/client.tsalways sentuse_external=falseexplicitly (the UI call sites inPrintersPage.tsxnever passeduseExternal). So calibration captured a frame from the built-in chamber camera and saved it as the reference; the runtime auto-check captured a frame from the external camera and diffed it against that built-in reference — a permanent difference well above any sane threshold, hence "not empty" on every print. Fix: the two routes now useuse_external: bool | None = None, and after the printer row is loaded they derive the default asbool(printer.external_camera_enabled and printer.external_camera_url and printer.external_camera_type)— identical to the runtime path's logic and the service-layer gate atplate_detection.py:605. Centralising the default on the backend means any current or future caller automatically gets the right camera without having to remember the flag. The frontend client now only forwardsuse_externalwhen the caller explicitly sets it (default omitted → backend decides), so the existing UI buttons immediately benefit. Power-user override path stays open: passing?use_external=falseon a printer with an external camera still wins, so anyone who deliberately wants a built-in-camera reference can still get one. Regression tests inbackend/tests/integration/test_camera_api.py:test_check_plate_defaults_use_external_when_external_camera_enabledandtest_calibrate_plate_defaults_use_external_when_external_camera_enabledpin the new default for a printer with external camera + URL + type set;test_check_plate_defaults_use_external_false_when_external_camera_disabledpins the built-in default for the no-external-camera case (the common path stays untouched);test_calibrate_plate_explicit_use_external_false_overrides_defaultpins the explicit-override escape hatch. All 11 plate-tagged camera integration tests pass; ruff clean; frontend build clean. -
API Keys page now exposes a narrowly-scoped "Update electricity price" toggle so the Home Assistant dynamic-tariff integration actually works (#1356, reported by @maziggy) — The reporter followed the Energy Tracking wiki page literally — "create a key with Write Settings permission, then PATCH
/api/v1/settingswith{energy_cost_per_kwh: ...}" — and hit{"detail":"API keys cannot be used for administrative operations"}. Triage showed three independent drifts: (1) the wiki listed nine fictional permissions ("Read Printers / Write Settings / Admin / …") but the actual UI inSettingsPage.tsx:3683-3744only ever exposed four toggles (Read Status, Manage Queue, Control Printer, Allow Cloud Access). There was no Write Settings toggle to tick. (2) Even if the UI had exposed it, the backend hard-deniesPermission.SETTINGS_UPDATEfor every API key via_APIKEY_DENIED_PERMISSIONSinbackend/app/core/auth.py— intentional protection becausePATCH /settingscan rewrite SMTP/LDAP/MQTT credentials and the HA access token, which would silently widen attack surface beyond what any documented use case needs. (3) So the wiki had been promising a workflow that was never deliverable. Fix: introduce a narrowly-scoped door for exactly the documented use case rather than relaxing the deny list. New columncan_update_energy_cost BOOLEAN DEFAULT FALSEonapi_keys(backend/app/models/api_key.py) with idempotent migration inbackend/app/core/database.py— defaults FALSE so existing keys never silently gain settings-write capability on upgrade. New endpointPOST /api/v1/settings/electricity-priceinbackend/app/api/routes/settings.pyaccepts{"energy_cost_per_kwh": <float ≥ 0>}— the field name matches what the wiki already documented so the HArest_commandexample needs only a URL+method change, not a payload change. New custom dependencyrequire_energy_cost_update()inbackend/app/core/auth.pybypasses the_APIKEY_DENIED_PERMISSIONScheck for this one route for API keys withcan_update_energy_cost=True; JWT users still go through the standardSETTINGS_UPDATEpermission check; auth-disabled deployments allow it (matches other settings routes). Crucially, the generalPATCH /settingsroute remains denied for API keys — flipping the narrow flag does NOT widen general settings-write access (regression test pins this). Schema/route wiring inbackend/app/schemas/api_key.py+backend/app/api/routes/api_keys.pyaccepts and returns the new field on create/update/list. Frontend: fifth toggle "Update electricity price" added to the create-API-key card inSettingsPage.tsxwith an amber "Energy" badge on existing keys that have it set;APIKey/APIKeyCreate/APIKeyUpdatetypes inapi/client.tsgained the new field; 16 new i18n keys (updateEnergyCost,updateEnergyCostDescription,energyCostBadge) added to all 8 locales — full German translation, English fallbacks elsewhere per project convention. Wiki rewrites:features/api-keys.md— replaced the fictional 9-row permissions table with the actual 5 toggles plus an info box explaining why no general Write Settings / Admin exists.features/energy.md— Home Assistant section now points atPOST /api/v1/settings/electricity-price, instructs users to tick the new permission, and adds a deprecation warning for users who built the integration from the old (broken)PATCH /settingsexample. Tests:backend/tests/integration/test_settings_electricity_price.py— 8 tests covering create-with-flag, default-off, API-key-with-flag updates persist, API-key-without-flag → 403, JWT admin user with SETTINGS_UPDATE allowed, anon → 401, negative price → 422 (Pydanticge=0), and the critical regression testtest_patch_settings_still_denied_with_energy_flagthat pins the narrow-flag-doesn't-widen-PATCH contract.frontend/src/__tests__/pages/SettingsPage.test.tsx— 2 new tests: Energy badge renders for keys with the flag, the toggle's value flows through to the POST body when the box is ticked. All 8 new backend tests + 32/32 SettingsPage tests pass; ruff clean; i18n parity passes; frontend build clean. -
Layer timelapse now starts for queue/VP-dispatched prints (#1353, reported by @Andlar94) — Reporter's external camera + go2rtc setup was configured correctly (Obico was happily polling the snapshot URL for ML plate detection) but no MP4 was ever produced. Logs showed
[LAYER-TL] Stitching layer timelapse for printer 1after each print yet no frames were ever captured and no[LAYER-TL] Attaching timelapse...follow-up appeared. Root cause:layer_timelapse.start_session()was only called from the two new-archive paths inon_print_start(backend/app/main.py:2510fallback path and2600regular new-archive). The expected-archive branch atmain.py:1981-2052— where every reprint and every queue/VP-dispatched print lands — updated the existing archive's status toprintingbut never started a timelapse session. So_background_layer_timelapseran at print-complete time, calledtl_complete(printer_id), found no active session in_active_sessions, silently returnedNone, and the wrapper atmain.py:3917produced no log message for the no-session case. Every print that came through the queue (or any reprint) silently lost its timelapse. Fix: mirror the sameif printer.external_camera_enabled and printer.external_camera_url: start_session(...)call in the expected-archive branch right after_active_printsregistration. The two pre-existing paths are untouched. Help-text correction: the snapshot URL field's tooltip previously read "Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection" — which is technically true but read as if filling in the URL was sufficient to enable those features. Reworded across all 8 locales to "Timelapse and plate detection each require their own per-printer toggle — this URL is just the image source they pull from when active" so admins know they still need to enable plate detection per-printer (separate toggle) and that timelapse only fires while a print is running. Regression tests inbackend/tests/unit/test_layer_timelapse_expected_archive.py:test_expected_archive_path_starts_timelapse_when_external_camera_enabledexercises the fullon_print_startflow with a registered expected print +external_camera_enabled=Trueand assertsstart_sessionis called with the expected-print archive_id (not a freshly created one);test_expected_archive_path_skips_timelapse_when_external_camera_disabledkeeps the existing gate in place so we don't try to capture from a None URL. 2 new tests pass; ruff clean; frontend i18n parity passes; bundle builds. -
Assign Spool now configures the slot even after a "Reset Slot" on A1 Mini BMCU / P1S Standard AMS (#1322 follow-up, reported by @RosdasHH) — The original fix widened empty-slot detection to
state == 11 OR tray_type != "", which closed the configured-slot reconfig case (PETG-over-PLA) but didn't help the "Reset Slot on printer screen with spool still inserted" flow: on these firmwares the AMS reportsstate=3, tray_type=""after a Reset Slot regardless of whether a spool is physically loaded. The empty-detection therefore decided "empty", skipped the MQTT publish, marked the assignment pending, and waited foron_ams_changeto re-fire when the AMS transitioned to "loaded" — but the AMS never transitioned, because nothing was changing physically. A deadlock with no escape from user actions. Reporter pinned it by removing theif not slot_is_empty:gate atbackend/app/api/routes/inventory.py:1302and verified the firmware accepts the MQTT push when a spool is present, even withstate=3, tray_type="". The original guard's rationale — "Bambu firmware silently drops ams_filament_setting / extrusion_cali_sel for unloaded slots" — turned out to be over-cautious: it's load-bearing only for slots that the firmware itself explicitly marks empty viastate == 9("no spool") orstate == 10("spool present but no feed"). For ambiguous states (state=3default-idle, missing-state on older firmwares), the AMS doesn't give us a reliable signal at all, so the safest bet is to treat the user's explicit Assign click as their assertion that a spool is there and let the firmware decide what to do with the push. Fix: the empty-detection now only short-circuits onstate ∈ {9, 10}— every other state attempts MQTT.pending_configis now driven by either the explicit-empty signal ORnot configured(so a printer-offline / no-client publish failure still flags the assignment as awaiting follow-up). Theon_ams_changereplay logic atbackend/app/main.py:1031is unchanged and still serves as the safety net for state=9/10 slots whose spools get inserted later (and for any truly-empty slot the firmware dropped — DBfingerprint_typestays empty until an AMS push actually provides one, so the replay still fires). Trade-off: for the rare case of "assign to a slot that really IS empty + state=3", the badge will show "Configured" even though firmware silently dropped the push. Most users assign right after inserting, so this is a small UI honesty cost in exchange for unblocking the much more common Reset-Slot workflow. Follow-up optimization (also @RosdasHH): the reporter then traced the raw MQTT payload and found that P1S / A1 Mini send only{"id": N}for a genuinely-empty slot — nostate, notray_type, no other fields. Without that signal, the assign path was firing one wasted MQTT publish per click on a truly-empty slot (firmware dropped it silently, but still). The AMS parser atbackend/app/services/printer_manager.py:788now detects the bare-tray shape (len(tray) == 1 and "id" in tray and state is None) and promotes it tostate=9— the firmware's explicit "no spool" code — which lets the inventory route's existingstate ∈ {9, 10}short-circuit apply. The detection is intentionally narrow: the post-Reset-Slot A1 Mini BMCU case sends a populated payload with empty values (state=3, tray_type=""), which has more than one key and stays unaffected — so the #1322 root fix is preserved. Regression tests inbackend/tests/integration/test_inventory_assign.py:test_post_reset_slot_with_state_3_still_fires_mqtt(renamed from the previous "marks_pending" test which was pinning the bug) andtest_state_missing_with_empty_tray_type_still_fires_mqtt(inverted from the legacy "older firmware empty → pending" assertion) pin the new behavior on the two firmware shapes the reporter hit.test_empty_tray_type_without_state_still_fires_mqttcovers the no-state SpoolBuddy case.test_no_ams_data_with_no_client_marks_pendingkeeps the printer-offline path producingpending_config=Trueso on_ams_change replay still triggers.test_state_empty_skips_mqtt_and_marks_pending(state=9) is unchanged — the firmware's explicit "no spool" still short-circuits correctly. The recentdd3e3f80k-profile fix was a separate red-herring path the reporter happened to also hit during testing; it stays as-is. All 28 inventory-assign tests + 312 inventory-tagged tests pass; ruff clean. Bare-tray follow-up tests:test_bare_tray_emulates_state_9andtest_populated_payload_with_empty_state_3_is_not_promotedinbackend/tests/unit/services/test_printer_manager.py— the second one is the explicit guard against regressing the #1322 root case by accident. -
Firmware update dialog now survives Cloudflare-blocked or transient outages on
bambulab.com(#1350, reported by @K1ngJony) — User's X1C on 01.10.00.00 saw "01.11.02.00 newer · Unavailable" plus the error "Firmware file for 01.11.02.00 is not available from Bambu Lab", and the logs showed repeatedFailed to get Bambu Lab page: 403warnings. Two problems stacked: (1)https://bambulab.com/en/support/firmware-download/all(the page Bambuddy scrapes to extract the Next.jsbuildIdused to fetch per-model JSON with download URLs) was returning 403 from the reporter's network — Cloudflare bot protection on bambulab.com is stricter than on the wiki and, prior to the 2026-05-12 compliance audit, the firmware-check service still claimed to be Chrome 120 via UA spoofing. The UA was updated to honestBambuddy/1.0in that audit butAccept/Accept-Languageheaders were never sent, so the request still tripped the "bare Python client" signal. (2) ThebuildIdwas cached in-memory only (1 h TTL), so every backend restart forced a fresh page fetch — meaning the first 403 from the user's network permanently broke download-URL resolution for that session even though the previous run had a perfectly valid buildId. Fix inbackend/app/services/firmware_check.py: (a) the httpx client now sendsAccept: text/html,application/json,*/*;q=0.8andAccept-Language: en-US,en;q=0.9alongside the existing honestBambuddy/1.0UA — both headers any normal client sends, no impersonation. (b)_get_build_id()gained a disk-cache layer at<data_dir>/firmware/build_id.json: successful fetches persist{build_id, fetched_at}to disk; the in-memory cache (fresh path, 1 h TTL) is checked first, then the disk cache seeds the in-memory slot on cold start, then the live fetch tries to refresh. On 403 or network error, we keep the cached buildId and set a newdownload_page_unreachableflag so callers can render an honest error. (c)_fetch_all_versions_from_download_pagenow retries once when a cached buildId returns 404 (Bambu rebuilt the page → invalidate + refetch + retry); on 403 it sets the unreachable flag and gives up gracefully without churning. Better error message inbackend/app/services/firmware_update.py: when a wiki-listed version has no download URL becausedownload_page_unreachableis true, the dialog now says"Could not reach Bambu Lab's firmware download page to fetch the file URL for X. Version is listed on the Bambu wiki but the download endpoint is unreachable from this network. Try again later, or download the firmware manually from bambulab.com and copy it to the printer's SD card."instead of the misleading"Firmware file for X is not available from Bambu Lab"(which implied Bambu didn't have the file, when actually we just couldn't reach Bambu). Version genuinely not in the catalog still gets the original message. Regression tests inbackend/tests/unit/test_firmware_versions.py:test_client_headers_identify_honestly_and_send_browser_acceptpins UA + Accept headers,test_build_id_is_persisted_to_diskconfirms the disk write on success,test_build_id_falls_back_to_disk_on_403reproduces the reporter's 403 with a pre-seeded disk cache,test_download_page_unreachable_flag_set_on_403_jsoncovers the per-model JSON endpoint 403 path,test_download_page_retries_once_when_buildid_staleproves the 404 retry. All 12 firmware tests + ruff clean. -
Subtype dropdown on the Add/Edit Spool form now offers
CF(carbon fiber) andGF(glass fiber) (#1345, reported by @maziggy) — The Subtype dropdown infrontend/src/components/spool-form/FilamentSection.tsxis populated from theKNOWN_VARIANTSarray infrontend/src/components/spool-form/constants.ts.CFandGFwere missing, so a user adding a third-party PETG-CF spool via the Material=PETG + Subtype=CF flow (the same shape Bambu's "PETG HF" already used) couldn't find the subtype in the list and had to type it freehand into the "create new" tail. Added both —CFto matchPETG-CF/PLA-CF/ASA-CF/PA-CF, andGFas the natural pair forABS-GF/PA6-GF.parsePresetNameinspool-form/utils.tsis unaffected: its materials list is iterated longest-first, so a cloud preset likeBambu PETG-CF Blackstill resolves to material=PETG-CFwith empty afterMaterial (the variant loop runs on""and finds nothing — no accidental Material=PETG / Subtype=CF rewrite). Frontend build clean. -
Spool-assignment dialog stacks correctly: the material-mismatch confirmation appears above its parent, and dashboard filament hover popovers no longer get covered by sibling printer cards (#1336 follow-up, mismatch case reported by @RosdasHH) — Two stacking-context regressions surfaced after the original z-50 → z-[100] bump on
AssignSpoolModallanded. (1) Material-mismatch ConfirmModal hidden behind its parent. Assigning a spool with a different material to the one configured on the slot opens a yellow warning ConfirmModal from insideAssignSpoolModal. ConfirmModal's overlay was hardcoded toz-50in its wrapper atfrontend/src/components/ConfirmModal.tsx, so once the parent moved toz-[100]the child sat behind it — the user clicked Assign, saw the parent dim slightly, and nothing visible to confirm. Added an optionaloverlayZIndex?: stringprop toConfirmModal(defaults toz-50so all 82 other call sites are untouched), and the mismatch site atAssignSpoolModal.tsx:584passesoverlayZIndex="z-[110]"so the warning sits above its parent. (2)FilamentHoverCard/EmptySlotHoverCardcovered by neighbouring printer cards. Hovering an AMS slot on the dashboard opens a "Jade White · Bambu PETG HF · K Factor 0.024 · 87% · Open in Inventory / Configure" popover. The popover was usingposition: absolutewithz-[60]inside its trigger — but each printer card on the dashboard creates its own stacking context (anyfilter: drop-shadow/transform/ positioned-with-z descendant is enough), andz-indexdoes not cross stacking-context boundaries: the next card in DOM order always wins regardless of how high the inner z-index goes. Visible as a "Jade White" tooltip getting half-eaten by the AMS-C tile column on the right neighbour card. Fixed by portaling both hover cards todocument.body(FilamentHoverCard.tsxviacreatePortalfromreact-dom) withposition: fixedand screen-space coordinates computed fromtriggerRef.current.getBoundingClientRect(). Coords are recomputed on visibility change, onscroll(capture phase) and onresizeso the popover tracks the trigger when the viewport moves; arequestAnimationFramere-measure after the initial paint avoids a one-frame flicker before the card has its rendered dimensions. Hover handlers wired on both the trigger AND the portaled card so moving the cursor from the slot tile onto the popover doesn't auto-dismiss it after 100 ms. The smart top/bottom placement logic (flips to below the trigger when there's not enough headroom above the fixed 56 px header) is preserved, as is the arrow pointer that points back at the slot.z-[60]stays — but it's now global because the popover lives at the root of the DOM, so it always beats dashboard widgets without conflicting with full-screen modals atz-[100]. All 20FilamentHoverCard, 17ConfirmModal, and 13AssignSpoolModaltests pass; frontend build clean. -
Deleting a print archive no longer wipes its filament / time / cost / energy contribution from Quick Stats (#1343, reported by @IndividualGhost1905) — Running the same model ten times and then deleting nine of the resulting archive entries (to keep the file list tidy) silently rewound the totals on the Statistics page:
total_prints,total_filament_grams,total_cost, and per-print energy all dropped back to whatever the surviving archive contributed, as if the other nine prints had never happened. Root cause: every metric inget_archive_statsatbackend/app/api/routes/archives.pyis recomputed on each render viaCOUNT/SUMover the livePrintArchiverows, so removing a row removes its contribution. (Energy in the default "Total" mode already survived archive deletion because it reads the smart-plug lifetime counters via_sum_live_plug_totals— that's the architectural shape we now generalise to the rest of the metrics.) Fix: soft delete with opt-in hard purge. New nullabledeleted_atcolumn onprint_archives(backend/app/models/archive.py) tracks rows the user removed from the UI. The DELETE endpoint atbackend/app/api/routes/archives.pynow accepts?purge_stats=true; default behaviour is to soft-delete — files removed from disk (still frees the storage), row hidden from listings, but the row stays in the table so the stats endpoint keeps counting it. Setting?purge_stats=truefalls back to the original hard-delete path for the rare case where the user actually wants the row out of Quick Stats too (e.g. failed prints that shouldn't pollute success-rate dashboards). The migration inbackend/app/core/database.pyadds the column dialect-conditionally —DATETIMEon SQLite,TIMESTAMPon PostgreSQL (PG doesn't acceptDATETIMEonALTER TABLEthe way it tolerates it insideCREATE TABLE) — plus an index ondeleted_atso theWHERE deleted_at IS NULLfilter that's now sprinkled across the listing queries stays cheap on big archive tables. Service-layer changes.ArchiveService.soft_delete_archiveis a new sibling ofdelete_archivethat reuses the existing on-disk path-safety checks (extracted into_resolve_archive_dir_for_deleteso soft and hard delete share the resolution rules — refuses paths outsidearchive_dir, refuses depth-zero paths) and flipsdeleted_at = now()aftershutil.rmtree. Listing methods now filterPrintArchive.deleted_at.is_(None):ArchiveService.list_archives,get_duplicate_hashes_and_names(a soft-deleted dupe must not inflate a group's count so the UI shows "1 of 1" instead of "1 of 10"),find_duplicates(both the exact-hash and the print-name paths), andArchiveComparisonService.find_similar_archives(both name-match and content-hash paths so the "Similar archives" panel doesn't suggest something the user just removed). The stats endpoint deliberately keeps NO filter — the whole point of #1343. Route-level reads tightened too:GET /archives/{id}returns 404 on soft-deleted rows so stale bookmarks don't expose hidden archives, search (both the SQLite FTS5 path and the LIKE fallback) skips them, the duplicate-group enrichment query inlist_archivesfilters them, and tag listing / archives-by-tag exclude them.GET /archives/slimandGET /archives/stats/exportintentionally do NOT filter so the dashboard widgets inStatsPage.tsxkeep aggregating across the full history. Frontend.ConfirmModalgained an optionalchildrenslot (frontend/src/components/ConfirmModal.tsx) so the delete-confirmation dialog can render an opt-in checkbox between the message and the action buttons without forcing a new bespoke modal.frontend/src/pages/ArchivesPage.tsx— both the card view and the detail view — now own adeletePurgeStatsboolean per component instance and pass it through toapi.deleteArchive(id, purgeStats)(frontend/src/api/client.tsappends?purge_stats=trueonly when the box is ticked). The checkbox resets to off on every modal close so the destructive option is opt-in per delete, never sticky. i18n: one new keyarchives.modal.deletePurgeStatsadded across all 8 locales — full German translation, English fallbacks elsewhere per project convention. Tests added tobackend/tests/integration/test_archives_api.py: soft delete preserves the row's contribution to total prints / filament / cost (the regression test for the reporter's exact scenario),?purge_stats=truedrops it from Quick Stats as before, soft-deleted archives 404 onGET /archives/{id}, soft-deleted archives are skipped by the search endpoint. All 42 pre-existing archive integration tests stay green, includingtest_delete_archive(which already asserts post-delete 404 — semantically equivalent under soft delete). FrontendConfirmModal(17 tests) andArchivesPage(23 tests) suites green, full build clean. -
OIDC provider login icons now render again — the strict SPA CSP no longer breaks them (#1333, PR #1342 by @netscout2001) — When an admin configured an OIDC provider with an external
icon_url(e.g.https://google.com/icon.png), the login page showed the browser's broken-image glyph instead of the IdP logo. Root cause: the SPA ships with the strict policyimg-src 'self' data: blob:so the entire admin UI cannot hot-link arbitrary external image hosts; admin-supplied icon URLs hit that wall on every render. Two options were on the table — loosenimg-srcto allowhttps:(one-line change but degrades the SPA's CSP everywhere), or proxy the bytes through the backend (this PR). The proxy path was chosen because (a) the SPA'simg-srcpolicy stays strict app-wide; (b) the existing MakerWorld thumbnail endpoint atbackend/app/services/makerworld.pyalready established the pattern with the same rationale; (c) anonymous login-page renders no longer leak each visitor's IP to the IdP host as a tracking signal — the proxy fetches the bytes once at admin-configure time and serves them from the same origin afterwards. Backend. NewMakerWorld-stylefetcher inbackend/app/services/oidc_icon.pystreams the response withfollow_redirects=False(so the SSRF host allowlist can't be bypassed via a 302 to a private address), enforces a MIME whitelist (PNG/JPEG/WebP/GIF; SVG is intentionally omitted — XML payloads carry too manyxlink:href/ external-ref corner cases for an MVP), and aborts at the first chunk past 1 MB so a hostile or misconfigured IdP serving a 500 MB payload cannot OOM the server. SSRF guardassert_safe_public_https_urlinbackend/app/api/routes/_oidc_helpers.pyis stricter than the Spoolman variant — Spoolman deliberately allows loopback / RFC-1918 (same-LAN deployment is the standard topology) while OIDC icons must live on the public internet, so private addresses there are SSRF probes. The shared SSRF data (cloud-metadata IP set covering AWS/GCP/Azure/Oracle/DO/Alibaba, numeric-encoded-IP regex, IPv4-mapped-IPv6 unwrap) was extracted tobackend/app/api/routes/_url_safety.pyso the two top-level guards share data but keep their distinct policies. The Pydantic_validate_icon_urlinbackend/app/schemas/auth.pynow lazy-imports the runtime SSRF guard so schema validation and the fetcher enforce the same allowlist — no drift between layers. Storage. Three new columns onoidc_providers(backend/app/models/oidc_provider.py):icon_data(LargeBinary,deferred=Trueso list queries don't pull the BLOB on every login-page render),icon_content_type(String(20), also serves as the has-icon indicator so the check never accidentally lazy-loads the BLOB),icon_etag(SHA-256 hex). A DB-layerCheckConstraintenforces the all-or-nothing triplet ((icon_data IS NULL) = (icon_content_type IS NULL) = (icon_etag IS NULL)) — fresh installs (SQLite + PostgreSQL) get it viametadata.create_all, stale PostgreSQL installs get it viaALTER TABLE ADD CONSTRAINTinbackend/app/core/database.py(SQLite cannotADD CONSTRAINTon an existing table, same trade-off as the existingdefault_group_idFK). The migration'sALTER TABLEis dialect-conditional —BLOBon SQLite,BYTEAon PostgreSQL. Routes. Four endpoints inbackend/app/api/routes/mfa.py:GET /oidc/providers/{id}/iconis public (no auth, same rationale as/api/v1/makerworld/thumbnail—<img>tags can't send Authorization headers, and the icon renders before the user is signed in), serves cached bytes with a strongETagandCache-Control: public, max-age=3600, supportsIf-None-Matchincluding theW/weak prefix, the*wildcard, and multi-token comma lists per RFC 7232.DELETE /oidc/providers/{id}/iconclears all four icon columns (URL + the three cached-bytes columns) — "Remove icon" means the whole record is gone, not just the cache, so the admin form doesn't end up in a confusing half-state where it shows a stale URL while the login page renders the Shield fallback.POST /oidc/providers/{id}/icon/refreshre-fetches from the stored URL for the "Refresh" button. Disabled providers respond 404 on the GET endpoint to avoid leaking their existence to anonymous callers.POST/PUTintegrate the fetcher transactionally: a failed fetch aborts with 400 before commit, so a bad URL on create leaves no half-configured row in the DB and a bad URL on update leaves the previous cached bytes intact. PUT with expliciticon_url: nullclears the icon record (detected via Pydantic'smodel_fields_set— distinct from "field omitted" which preserves it). Both fetch failures and SSRF rejections log at WARNING with the URL redacted (query string and fragment stripped via_redact_url_for_log) so admin-supplied presigned URLs carryingX-Amz-Signature=...or bearer tokens can't end up in operator log files. Frontend.frontend/src/pages/LoginPage.tsxextracts anOIDCProviderButtonsub-component so each provider owns its owniconFailedstate — on<img>error (provider deleted between page load and image fetch, network blip, etc.) the SPA swaps in theShieldfallback rather than showing the broken-image glyph to anonymous users.frontend/src/components/OIDCProviderSettings.tsxdoes the same withProviderIconAvatar(Globe fallback) and adds Refresh / Remove buttons. The new same-origin proxy URL helperapi.oidcProviderIconUrl(id)returns aSameOriginUrl-branded string so a future caller can't accidentally substitute an attacker-controlled URL where this is consumed. Four new i18n keys (refreshIcon,removeIcon,iconRefreshed,iconRemoved,iconFetchFailed) added across all 8 locales. Tests. About 100 new tests covering the streaming fetcher (MIME whitelist, status codes, redirect rejection, size-cap early-exit including the first-oversized-chunk guarantee, missing Content-Type distinct message,httpx.InvalidURLmapping), the OIDC SSRF guard (explicitly asserts that Spoolman-allowed cases like loopback / RFC-1918 /localhostare rejected here so the two guards do not silently converge), Pydantic-validator parity (numeric-encoded IPs, cloud metadata, multicast, IPv4-mapped IPv6 all rejected at schema-validate time), the dialect-conditionalALTER TABLEmigration (both BLOB and BYTEA paths via patchedis_sqlite()), the full create/update/delete/refresh flow including atomicity (failed fetch preserves prior state), the upgrade-path edge case (icon_urlpresent but no cached bytes → refetch on next save), ETag/304 withW/weak prefix and*wildcard, raw-SQL inconsistent-triplet 404 defence, the PG→SQLite-ZIP backup BLOB type-mapping round-trip, and a CSP regression-guard test inbackend/tests/integration/test_security_headers.pythat asserts the SPA default CSP block does not includehttps:inimg-src— so a future contributor "fixing" a broken icon by relaxing CSP discovers the proxy pattern instead. Frontend tests inLoginPage.test.tsxandOIDCProviderSettings.test.tsxcoverhas_icon: true|false, mixed providers on the same page,<img>error → Shield/Globe fallback, and per-provider state isolation (twohas_icon: trueproviders; firingerroron A leaves B's icon intact — locks in the sub-component extraction so a future hoist ofuseStateto the parent loop is caught by CI). Manually verified end-to-end against a live PocketID instance with multiple icon URLs. Follow-on tightening:has_iconis now a required field onOIDCProviderResponse(no Pydantic default — fails loudly if any future caller skips_build_provider_response), backed by anOIDCProvider.has_icon@propertyreadingicon_content_type. Inupdate_oidc_providerthe icon refetch was moved BEFORE the setattr loop, so on fetch failure the in-memory ORM object stays consistent (DB row was already safe viaget_db()'s rollback; this closes the in-memory window too). Patched by @netscout2001. -
Backup tab indicator dot now turns green when Scheduled (local) Backups is enabled (#1331, PR #1338 by @chanakyan-arivumani) — Toggling Scheduled Backups on inside Settings → Backup left the sidebar tab indicator dot stuck on grey: the visual cue that there's an active backup configuration was lost for users who run scheduled local backups without GitHub. Two stacked layers caused it: (1) the dot condition at
SettingsPage.tsx:1461only checked the GitHub chain (cloudAuthStatus?.is_authenticated && githubBackupStatus?.configured && githubBackupStatus?.enabled);settings?.local_backup_enabledwas never consulted, so the scheduled-backup state had no path to the indicator. (2) The toggle handler inGitHubBackupSettings.tsxcalledapi.updateSettings({ local_backup_enabled })but never invalidated the['settings']query cache, soSettingsPagekept reading the stale value — the indicator would only update on a full page reload even if the condition fix were in place. Two-line fix: extend the dot's predicate to... || settings?.local_backup_enabledand addqueryClient.invalidateQueries({ queryKey: ['settings'] })after a successful save (matching the existing invalidation pattern atGitHubBackupSettings.tsx:402/463/477/497). The GitHub-chain short-circuits first so the common case is unchanged. Patched by @chanakyan-arivumani. -
Color catalog presets now apply
extra_colors(gradient stops) andeffect_type(sparkle / wood / marble / glow / matte) onto the spool, not just hex + name (#1340, reported by @maugsburger) — Creating a catalog entry that pairs a base color with multi-color gradient stops and a visual effect, then clicking that swatch in the Edit Spool dialog, only copiedcolor_nameandrgbaover — theextra_colorsandeffect_typefields were silently dropped. The data was flowing from the backend correctly (GET /api/v1/inventory/color-catalogreturns both fields per theColorCatalogEntryschema infrontend/src/api/client.ts), but three layers above stripped them: (1)SpoolFormModal.tsxtyped itscolorCatalogstate with a narrower shape that omitted the two fields; (2)ColorSection.tsxmapped catalog entries toCatalogDisplayColor(the typed-down shape rendered on swatches) without propagating them; (3) theselectColor()handler only setrgba+color_nameon click. Fix: widened both types inspool-form/types.ts(CatalogDisplayColor+ColorSectionProps.catalogColors) to carry the optionalextra_colorsandeffect_type, propagated them through the fourmatchedCatalogColorsmapping callbacks (byBrand / exact full-material / normalized-trailing-+/ base-material prefix), and extendedselectColorto take optionalextraColors/effectTypeparameters. Semantic rule: catalog swatches are complete presets — picking one writes BOTH gradient and effect from the entry (overwriting any existing values), so a gradient catalog entry applies its stops AND a solid catalog entry clears any old gradient that was on the spool. Recent-colors and the hardcoded-fallback palette are plain hex pickers — picking one keeps any existingextra_colors/effect_typeuntouched, since those swatches aren't presets, just color picks. Bonus: fixed the en-US spelling drift the reporter flagged in their nitpick —'Extra colours'and'wrong colour loaded'strings (which had been seeded into all 8 locale files as English fallbacks) standardized to'Extra colors'and'wrong color loaded'; matching comment blocks (// Multi-colour ...) normalized in the same pass. Regression tests in__tests__/components/spool-form/ColorSectionCatalogExtras.test.tsx(3 cases): catalog click with gradient + effect propagates all four fields toupdateField, catalog click on a solid preset clears any pre-existing extras/effect (preset-replaces-look semantic), and fallback palette click leaves extras/effect untouched. All 23 spool-form tests + 8 i18n parity tests pass; build clean. -
Assigning a spool to an unconfigured AMS slot no longer silently skips MQTT on A1 Mini / P1S firmware — and the "PETG over a PLA-configured slot won't reconfigure" symptom is fixed in the same change (#1322, reported by @RosdasHH) — On the user's A1 Mini BMCU (firmware 01.07.02.00) and P1S Standard AMS (firmware 00.00.06.75), pressing "Assign Spool" on any slot left the slot unconfigured: the DB row was created with
pending_config=True, the MQTT publish was skipped, and the log linePre-configured assignment: ... (slot empty, will configure on insert)fired even though the spool was physically loaded. The same code path also blocked the "swap PLA to PETG in the same slot" flow — Bambuddy would keep treating the spool as PLA because the publish never reached the printer. Root cause: the empty-slot detection atbackend/app/api/routes/inventory.py:1267preferredtray.state == 11("filament fed to extruder") overtray_type, falling back totray_typeonly whenstatewas missing entirely. Reporter's AMS dumps showedstate == 3on every slot — configured and unconfigured, on both printers — andstatewas never absent. So the state-only branch always fired, the result was always "empty", and MQTT was always skipped regardless of whether the slot was actually loaded. The "fingerprint_type empty → defer until insert" pre-config replay atbackend/app/main.py:1026had the samecur_state == 11gate, so even when the user manually configured the slot in Bambu Studio afterward (makingtray_typego from""to"PLA"), the deferred MQTT publish never fired because state stayed at 3. Fix: both call sites now use a disjunction — the slot is treated as loaded when eitherstate == 11ortray_typeis non-empty. The "Reset slot" case (state=11 + tray_type="") that the original state-only check was protecting still works through the first clause; the configured-slot case (state=3 + tray_type="PLA") on firmwares that never set state=11 now works through the second; and truly empty unconfigured slots (state≠11 + tray_type="") still fall through to the pending-config path correctly. The on_ams_change replay's disjunction also fires the deferred publish when the user later configures the slot through Bambu Studio, since that flipstray_typenon-empty even if state stays at 3. Caveat: for a truly empty slot with a 3rd-party non-RFID spool that the user physically inserted, neither signal points to "loaded" on these firmwares, so we still can't auto-fire the publish until the slot gets configured (manually or by another assign). The pending-config row persists in the DB and gets applied on the next AMS push that flipstray_typenon-empty. Regression tests: 3 intest_inventory_assign.py—test_state_never_eleven_firmware_with_loaded_tray_fires_mqtt(state=3 + tray_type='PLA' → MQTT fires; pins the reporter's primary symptom and the PETG-over-PLA secondary symptom which goes through the same predicate),test_state_never_eleven_firmware_with_empty_tray_marks_pending(state=3 + tray_type='' still pending — confirms the disjunction didn't accidentally turn truly empty slots into the loaded branch), andtest_on_ams_change_fires_replay_when_tray_type_appears_without_state_11(pre-existing SpoolBuddy-style assignment with empty fingerprint; tray_type going''→'PLA'on a state=3 firmware fires the deferred publish even though state never becomes 11). All 28 tests in the file pass; ruff clean. -
Assign Spool / Inventory search: numeric spool ID lookup is back, and Unassign in Spoolman mode no longer stays permanently disabled (#1336, reported by @S0liter) — Two independent regressions surfaced from the same report. (1) Numeric ID search: typing a Spoolman spool's numeric ID into the search box on the "Assign Spool" dialog (or on the Inventory page) returned no results. The shared search helper
spoolMatchesQueryatfrontend/src/utils/inventorySearch.ts:7only checked the text fields (material,brand,color_name,subtype,note,slicer_filament_name,storage_location) — the spool'sidwas not part of the predicate, so a query like12only matched when "12" happened to be a substring of one of the text fields. One-line fix: the predicate now also testsString(spool.id).includes(q), mirroring the case-insensitive substring semantics of the other fields. Covers both call sites: the Assign Spool dialog (AssignSpoolModal.tsx:255for local inventory +:446for Spoolman) and the main Inventory page (InventoryPage.tsx:871). New regression test in__tests__/utils/inventorySearch.test.tspins exact-match ('42'→ id 42), substring ('4'→ id 42), and non-match ('99'→ id 42 rejected) so the predicate can't drift back into "text only" silently. (2) Unassign button stuck disabled in Spoolman mode: opening the edit modal on a Spoolman spool that was assigned to an AMS slot left the Unassign button greyed out — the user had no way to release the spool back to "available". The modal atSpoolFormModal.tsx:526only ever queriedapi.getAssignments()(the legacy localspool_assignmentstable) and looked up bya.spool_id === spool.id. In Spoolman mode the slot assignment lives in the separatespoolman_slot_assignmentstable, keyed byspoolman_spool_id— so the lookup always returnedundefined, the button'sdisabled={isPending || !spoolAssignment}predicate stayed true forever, andunassignMutationwas also pointing at the wrong API (unassignSpoolinstead ofunassignSpoolmanSlot). Both the query and the mutation now branch on the existingspoolmanModeprop: Spoolman mode usesgetSpoolmanSlotAssignments()+ lookup byspoolman_spool_id+unassignSpoolmanSlot(spool.id)and invalidates thespoolman-slot-assignments-all/spoolman-slot-assignmentsquery keys; local mode keeps the existing path unchanged. Two new regression tests in__tests__/components/SpoolFormModal.test.tsx(SpoolFormModal — Unassign button (#1336)): the button is enabled and clicking it callsunassignSpoolmanSlot(42)when a matchingspoolman_slot_assignmentexists, and the button stays disabled (nounassignSpoolfallback) when no assignment exists. All 12 search-helper tests + 13 InventoryPage search tests + 27 SpoolFormModal tests pass; frontend build clean. -
Spoolman auto-create no longer labels Bambu Lab RFID spools with competitor names like "3DXTECH™ Black" (#1309, PR #1330 by @ojimpo) — When Bambuddy auto-created a Spoolman filament entry for a Bambu Lab RFID spool, the second-stage lookup against Spoolman's external library (
GET /api/v1/external/filament, served from SpoolmanDB) matched onmaterial + color_hexonly — there was nomanufacturer/vendorfilter. The catalog is multi-vendor and roughly ID-sorted: for PLA +#000000(black) it contains 64 entries, with the first hit being3djake_pla_black_1000_175_n(3DJAKE), the third being3dxtech_pla_carbonxcarbonfiberblack_500_175_p(3DXTECH, nameCarbonX™ Carbon Fiber Black), and the actualbambulab_pla_black_1000_175_nnot surfacing until position 15. Bambuddy therefore created the filament under the Bambu Lab vendor but labeled it with a competitor's product name. Real-world observations in production: Bambu Lab ABS Black created as3DXTECH™ Black, Bambu Lab PLA Support picked the adjacent / wrong variant instead ofbambulab_pla_supportforpla/petgblack_500_175_n, and PLA Basic Black created asPLA(material, notPLA Basic). A secondary issue compounded this:_create_filament_from_externaldropped the external entry'sdensityfield, so even when the correct entry was eventually picked the density got overwritten bycreate_filament's built-in PLA-default 1.24 fallback instead of the catalog's actual value (1.26 for PLA Basic, 1.31 for PETG, etc.). Fix inbackend/app/services/spoolman.py::_find_or_create_filament: (1) the external-library loop now filters bymanufacturer == "Bambu Lab"(case-insensitive, whitespace-trimmed), with a defensiveid.startswith("bambulab_")fallback that handles entries where themanufacturerfield is missing or has drifted in a future SpoolmanDB schema. (2) When multiple Bambu Lab candidates match the samematerial + color_hex, the function prefers the entry whosenameequals the AMStray_sub_brands(lowercase+strip comparison) so the more specific variant wins —PLA Basicover genericBlack,Support for PLA/PETG Blackover genericBlack, etc. (3)_create_filament_from_externalnow propagatesexternal.get("density")through tocreate_filament; when the catalog entry has no density set, the existing material-table fallback insidecreate_filamentstill kicks in via theif density is Nonebranch at line 321 — no path lost. Behavioural caveat the user needs to know: previously-created mis-named filaments are NOT auto-renamed by this fix. Step 1 of_find_or_create_filamentis the internal-Spoolman-filament loop that short-circuits on(vendor == "Bambu Lab", material, color_hex)— and that path is unchanged. Any Bambu Lab filament created by an older Bambuddy build (or hand-edited by the user) will continue to be matched and reused on subsequent AMS reads, regardless of how wrong its name is. To pick up the corrected name, the user has to delete the mis-named filament in Spoolman once — then the next AMS read for the same material+color falls through to the external-library step and creates a new entry with the correct Bambu Lab name. This is deliberate: some users may have intentionally renamed Bambu Lab filaments (e.g. to follow their own naming convention or to merge variants) and a silent auto-rename would undo that. Regression tests intest_spoolman_service.py::TestFindOrCreateFilament(6 new): internal short-circuit preserves the existing match without touching the external library, non-Bambu-Lab external entries are skipped even when they sort first in SpoolmanDB,PLA Basicwins over genericBlackvia thetray_sub_brandstiebreaker (per maintainer request on #1309), no-match-anywhere falls back totray_sub_brands or tray_typeinstead of leaking a competitor name into the create call,id.startswith("bambulab_")accepts entries with absentmanufacturerfield, and density propagates end-to-end through the public method instead of getting clobbered by the material-default. All 44 tests intest_spoolman_service.pypass; ruff clean. Reported and patched by @ojimpo. -
Safety: bed-jog Z direction was inverted on A1 / A1 Mini — "Up" rammed the nozzle into the bed (#1334, reported by william.filipcic@gmail.com) — On A1 / A1 Mini, clicking the "Up" arrow on the printer-card bed-jog control would send the nozzle straight into the build plate. Reporter triggered it with the 50 mm step and crashed their nozzle. Root cause: the bed-jog UI was designed against the X1 / P1 / H2 family's bed-on-Z convention. On those printers the bed is the Z-axis, Bambu's firmware homes Z=0 at the top, and
G1 Z-raises the bed toward the toolhead (decreases the nozzle-bed gap). The frontend maps "Up" to negative distance with that convention in mind. A1 / A1 Mini are bed-slingers: the bed moves on Y, the toolhead moves on X+Z, and the firmware uses standard cartesian Z (Z+ = toolhead up). On those modelsG1 Z-10drives the toolhead down 10 mm — straight through any clearance the user had — which is exactly what the reporter saw. There was no model classification at the bed-jog code path; every printer got the same X1-convention G-code. Fix: newis_bed_slinger(model)helper atbackend/app/services/printer_manager.py(sibling to existingsupports_chamber_temp/has_stg_cur_idle_bug, reuses the already-definedA1_MODELSfrozenset which covers display names and internal codesN1/N2S). The bed-jog route atbackend/app/api/routes/printers.py:2710now inverts the signed distance before emitting the G-code when the printer model is in that set, so the UI "Up" semantics ("decrease nozzle-bed gap") stay consistent regardless of which physical part moves on the printer. Frontend stays untouched — single source of truth for the direction logic lives in the backend, keyed off the printer'smodelcolumn, so any future bed-slinger Bambu model only needs one frozenset update. The route'sQuerydescription and docstring now state the new contract explicitly: distance is the gap adjustment, not the raw Z value, and the backend translates per model. Regression tests: 13 intest_bed_jog.py::TestBedJogAPI— 6 parametrised cases prove bed-on-Z models (X1C / P1S / H2D / H2S / H2C / P2S) still emitG1 Z-10.00for a UI "Up" click (pass-through), 6 parametrised cases prove A1 / A1 Mini / A1MINI / A1-MINI / N1 / N2S emitG1 Z10.00instead (inverted, toolhead up), plus 1 symmetric "Down arrow drops the toolhead viaG1 Z-" case. 5 intest_printer_manager.py::TestIsBedSlingerpin the helper's classification contract — A1 family true, every bed-on-Z model false, None / empty-string safe, case-insensitive. Safety note: if you own an A1 or A1 Mini and were running any 0.2.x build before this release, do not use the printer-card bed-jog buttons — they will move the toolhead in the wrong direction. The Z controls in Bambu Studio / Bambu Handy are unaffected (they generate their own model-aware G-code). -
Spoolman inventory: editing a spool's color name no longer "reverts" to the subtype on save (#1319, reported by @MartinNYHC) — On Spoolman-backed inventory, changing a spool's color name in the edit dialog appeared to accept the new value but the inventory list column and the next edit-dialog open showed it back to the subtype string. Three layers stacked on top of each other to produce this: (1)
find_or_create_filamentatbackend/app/services/spoolman.py:609matches existing Spoolman filaments bymaterial / name / color_hex / vendor—color_nameis intentionally not part of the match key (Spoolman doesn't standardise the field and most installs leave it null) — but when a match was found it returned the existing filament's id unchanged, silently dropping the newcolor_namevalue. The write never reached Spoolman. (2) On re-read, the helper at_spoolman_helpers.py:279falls back tosubtypewhenfilament.color_nameis empty (without the fallback, Spoolman installs that don't fill the field would render every spool as "Unknown color"). The persisted value was still empty, so the read synthesised the column fromsubtype. (3) The edit form prefilledcolor_namefromspool.color_name— which on Spoolman installs withoutcolor_namewas the synth value (= subtype). If the user changedsubtypebut notcolor_name, the form silently round-tripped the OLD subtype back to Spoolman as if it were a real user-setcolor_name, which then started showing up as the persisted value on the next render — the exact "color reverts to subtype" pattern in the bug report. Fixes: (1)find_or_create_filamentnow patches the matched filament'scolor_namevia the existingpatch_filamentPATCH wrapper when the request differs from what's stored. Convention on the parameter:None= "don't touch",""= explicit clear (patches Spoolman tonull), any other string = set/update. (2) The PATCH route atspoolman_inventory.py:567now uses Pydantic'smodel_fields_setto distinguish "field omitted" from "field explicitly set to null" — only the latter is a clear (mirrors the existingstorage_locationpattern at the same site). (3) The map helper now also returnscolor_name_is_synthesized: boolon every inventory record, andSpoolFormModal.tsxchecks it on prefill so the input starts blank when the value was synthesised from subtype — the user sees the real stored state and can't accidentally round-trip the synth value back. The read-side fallback is kept on purpose (the list-display "Unknown color" problem hasn't gone away — it's just that the form no longer treats the fallback as a real value). Apatch_filamentfailure is caught and logged but doesn't block the match — the spool still links to the correct filament, only the colour-name update is dropped, which is the safer failure mode. Regression tests: 5 intest_spoolman_inventory_methods.py::TestFindOrCreateFilament— patch-on-change, no-patch-when-unchanged, no-patch-when-None, clear-when-""-passed, and patch-failure-still-returns-match-id. 2 intest_spoolman_inventory_helpers.py::TestMapSpoolmanSpool—color_name_is_synthesizedflag isFalsewhen a real value is stored,Truewhen the fallback fires. 2 integration tests intest_spoolman_inventory_api.py— wire-levelcolor_name=nullclears (route translates to""), andcolor_nameomitted from the PATCH body keeps the current value (route passesNone). All 564 spoolman-tagged tests pass; ruff clean; frontend build clean. -
Deleting an SSO user left orphan OIDC/MFA/camera-token rows on SQLite — blocked re-login and leaked auth state (#1285, PR #1295 by @netscout2001) — On SQLite (default deployment) the
delete_userroute left orphan rows inuser_oidc_links,user_totp,user_otp_codes, andlong_lived_tokensbecause the project intentionally runs withPRAGMA foreign_keys=OFF, so theON DELETE CASCADEdeclared on those tables never fired. Reported symptom: an admin deleted an OIDC-provisioned user, the user tried to re-login via SSO, the OIDC callback found the orphanUserOIDCLinkpointing at the (now missing) user, failed to resolve it, and redirected toaccount_inactiveinstead of triggeringauto_create_users. The same root cause was leaking MFA secrets (user_totp), pending email OTP codes (user_otp_codes), and per-user camera-stream tokens (long_lived_tokens—verify()would happily match bylookup_prefixeven after the owning user was gone). PostgreSQL deployments were unaffected — cascade was firing there. Fix: mirrors the existingAPIKeycleanup pattern indelete_user(introduced in PR #1182).backend/app/api/routes/users.py:delete_usernow explicitly deletesUserOIDCLink,UserTOTP,UserOTPCode, andLongLivedTokenrows owned by the user; also folds inPrintBatch.created_by_idcleanup (sameondelete=SET NULLSQLite-FK-off root cause, theSET NULLblock atusers.py:393-407was missing it).backend/app/core/database.py:run_migrationsgains an idempotent startup orphan-cleanup that sweeps the four auth tables (DELETE FROM <table> WHERE user_id NOT IN (SELECT id FROM users)), wrapped inbegin_nested(), logged at INFO only when rows actually drop — so installations carrying orphans from before the fix are healed automatically without manual DB intervention. No-op on Postgres (cascade already fired) and idempotent on SQLite (second run finds nothing).backend/app/api/routes/mfa.py:list_oidc_linksreturns"<deleted>"forprovider_namewhenlink.provideris null instead of raisingAttributeError— covers the symmetric edge case where aUserOIDCLinkcould reference an orphaned provider. Tests: 14 new/extended.test_users_auth_cleanup.py(new): 5 tests verifydelete_userremoves OIDC/TOTP/OTP/long-lived-token rows individually + combined-cleanup atomically.test_oidc_relogin.py(new): full end-to-end test reproducing the #1285 symptom — mocked IdP, first OIDC login, admin delete, second OIDC login provesauto_create_usersfires again (and pinned the regression boundary by confirming the test fails without the fix).test_orphan_auth_cleanup_migration.py(new): 7 tests for per-table cleanup across all four auth tables, idempotency, no-op on fresh install, and survival of rows belonging to real users.test_mfa_api.pyaddsTestListOidcLinksDefensiveProviderNullfor the null-check.test_auth_api.py::test_delete_userextended to assert all five auth-table side effects (UserOIDCLink,UserTOTP,UserOTPCode,APIKey,LongLivedToken). All 13 PR-added tests + 194 tests in extended files pass; ruff clean. Reported and patched by @netscout2001. -
Slicer bundle import 400/502/503 errors now land in the log so support bundles tell us why (#1312, reported by @hasmar04) — Reporter hit
400 Bad RequestfromPOST /api/v1/slicer/bundleswhen uploading a Bambu Studio Printer Preset Bundle (.bbscfg); a second contributor had reported the same shape the day before. Same bundle file uploaded fine on Martin's dev machine, which strongly points at sidecar-side differences (image version, write permissions onDATA_PATH/bundles, TrueNAS Docker volume perms, etc.) — but triage was blocked because the sidecar's actual reject reason only made it as far as the FE toast. Bambuddy logged just the uvicorn-access line (POST /api/v1/slicer/bundles HTTP/1.1 400), with no detail in the support bundle. The route atbackend/app/api/routes/slicer_presets.py:import_slicer_bundlenow emits alogger.warningfor each of the three failure shapes: 400 (SlicerInputError) — sidecar's reject string is logged alongside the filename and byte count, so we can see "bundle rejected becausemanifest.jsonis missing" in the next support bundle without asking the reporter to copy the toast text. 503 (SlicerApiUnavailableError) — logs the configured sidecar URL plus the exception detail (separates "URL wrong" from "sidecar offline"). 502 (SlicerApiError) — logs filename + byte count + error string, useful when the sidecar'sDATA_PATH/bundleswrite fails (the typical 5xx cause on this path). The 400 case isWARNINGrather thanINFOdeliberately — it's an unexpected end-user-visible failure, not a routine event. Existingtest_import_bundle_sidecar_400_passes_throughnow also asserts the reject reason AND the filename appear in caplog, so the support-bundle-includes-the-diagnostic contract is pinned. Doesn't fix #1312's actual root cause (sidecar-side, still under investigation with reporter) — but the next reporter we get on this code path will produce a bundle that contains the answer. -
Restarting Bambuddy mid-print triggered plate-check pause + duplicate archive (#1304, reported by @kleinwareio) — When a P1S print was in progress and the user updated the Bambuddy container (
latest→dailyin the report, but the same path fires on any restart), Bambuddy paused the live print with an "Object detected on build plate" warning AND re-archived the in-progress file as a duplicate. Root cause: the print-start detector atbackend/app/services/bambu_mqtt.py:2780gated onself._previous_gcode_state != "RUNNING", which is true whether we just saw IDLE→RUNNING (a real print start) OR we just constructed a fresh BambuMQTTClient and_previous_gcode_stateis still its initialNone(catch-up push from a printer already running). The fresh-client case firedon_print_start, which downstream ran the plate-detection-and-pause flow atmain.pyAND the FTP-download-and-archive flow — exactly the two symptoms in the bug report. Fix: addedself._previous_gcode_state is not Noneto theis_new_printguard, so the first push from the printer in a new process lifetime never counts as a state transition into RUNNING._was_runningstill flips toTruevia the unconditional "Track RUNNING state" block atbambu_mqtt.py:2795, so print-completion detection keeps working — only the start callback is suppressed. Three existing tests that asserted on the old (buggy) behavior were updated to seed_previous_gcode_state = "IDLE"first, matching the realistic lifecycle of a print actually starting (Bambuddy has been observing IDLE/FINISH before RUNNING); they now exercise the correct path. New regression testtest_first_running_push_after_bambuddy_restart_does_not_fire_print_startpins the contract for the reporter's exact scenario — and asserts that_was_runningstill becomes True so completion still fires when the print ends. Theis_file_changebranch was unaffected (it already required_previous_gcode_file is not None, so restart-catch-up never reached it anyway). -
Create User form rejected weak passwords with an opaque "HTTP 422" toast (#1303, reported by @TrickShotMLG02) — Three independent UX gaps stacked on top of each other. (1) Discoverability: the Create User and Edit User modals showed no hint about the backend's password complexity requirements (
min 8 chars+ uppercase + lowercase + digit + special character; enforced inbackend/app/schemas/auth.py:_validate_password_complexity). Reporter typed an 8-character all-digits password and had no way to know why it failed. (2) Validation mismatch: the frontend's pre-submit check atSettingsPage.tsxwas onlypassword.length < 6, accepting passwords the backend would reject — every weak password got bounced after the round-trip instead of getting blocked locally. (3) Error display fragility: when the backend returned a 422 with a Pydantic detail array, the API client's error parser atfrontend/src/api/client.ts:107could fall through to the bareHTTP ${status}fallback if the mapped/filtered detail array ended up empty after stripping the"Value error, "prefix — masking the real reason as just "HTTP 422". Fixes: (1) added apasswordRequirementshelper line under both password inputs in Create User / Edit User; (2) extractedcheckPasswordComplexityintofrontend/src/utils/password.ts, called fromhandleCreateUserandhandleUpdateUserbefore the API request — it returns the same FIRST failing rule the backend's validator would have flagged (uppercase before lowercase before digit before special, matching_validate_password_complexity's order — fixing one rule shouldn't immediately trip a different message), and the submit button is disabled until all rules pass; (3) the API client now falls back toJSON.stringify(detail)when the mapped array is empty, so a malformed but non-empty 422 detail surfaces SOMETHING informative instead of a bare status code. New translation keyssettings.passwordRequirements,settings.toast.passwordNeeds{Uppercase, Lowercase, Digit, Special}, plus the existingpasswordTooShorttext updated from "6 characters" to "8 characters". English + German fully translated (German reporter's locale); FR/IT/PT-BR translated using straightforward equivalents; JA/ZH-CN/ZH-TW seeded with English for the new complexity messages (existing project flow for new strings). 7 new unit tests infrontend/src/__tests__/utils/password.test.tspin the validator's contract, including the reporter's exact"12345678"input which now produces a local "Password must contain at least one uppercase letter" toast instead of a 422 round-trip. -
External NAS scan hung forever and never committed subdirectories (#1299, reported by @joeferrante) — Linking an external mount with ~1200 subdirectories caused the "Link External Folder" modal to spin until the FE gave up, after which the mount appeared in the sidebar but with no subdirectories, and subsequent scans had no effect either. The reporter's support bundle pinpointed two compounding problems. (1)
TypeError: unsupported operand type(s) for /: 'str' and 'str'on every STL — 1,606 instances in the log.generate_stl_thumbnailatstl_thumbnail.py:119doesthumbnails_dir / thumb_filename, which requires aPath, but the external-scan call site atlibrary.py:1256passed both arguments asstr(generate_stl_thumbnail(str(filepath), str(thumb_dir))). Every STL crashed inside thetry/exceptand got logged at WARNING level — visible spam but more importantly wasted work (trimesh.load()and matplotlib setup ran before the failing division). Fix: defensivePath()coerce at the top ofgenerate_stl_thumbnailso the function works regardless of how callers pass args. Regression testtest_string_arguments_accepted_without_typeerrorpins the contract. (2) Scan ran STL thumbnail generation synchronously inside the HTTP request — even after fix (1),trimesh.load()+ matplotlib render is 1–5 seconds per STL; on a NAS with thousands of STLs that's hours of work blocking the modal. Frontend would time out, user would refresh, the HTTP request would be cancelled,db.commit()atlibrary.py:1331would never run, and no folder/file rows would be committed — which is exactly why "subsequent scans have no effect" (each retry started from scratch and hit the same wall). Fix: scan now defers STL thumbnails to a background task. Afterdb.commit(), the route spawnsasyncio.create_task(_backfill_external_stl_thumbnails(folder_ids))with the full set of folder IDs fromfolder_cache.values()(covers both pre-existing subfolders AND the ones created during this scan —all_folder_idsis snapshotted before the walk and would have missed the new ones), then returns immediately. The background task opens its ownasync_session, walks every STL file withthumbnail_path IS NULLin the linked folder tree, generates each thumbnail, and commits per-file so a server restart mid-run only loses the in-flight thumbnail. Survives FE refresh because the task lives in the FastAPI event loop, not the request scope. The reporter's smaller mount (/mnt/NAS_3d_files/3mf_Files, 4 subdirectories) used to work because it completed inside the FE timeout window — with this fix, the 1200-subdir parent mount completes equally fast and thumbnails fill in over the following minutes. Auto-scan after create unchanged:FileManagerPage.tsx:1147-1151still callsscanExternalFolderimmediately aftercreateExternalFolder, which is correct UX — what changed is that the scan response now arrives in seconds instead of timing out. -
MakerWorld "Open Cloud settings" link landed on the wrong page (#1300) — On the MakerWorld page, the "Open Cloud settings" hyperlink shown in the sign-in-required banner (when no Bambu Cloud token is stored) pointed at
/settings?tab=cloud. The Settings page has nocloudtab (its tabs are general/plugs/notifications/queue/filament/network/apikeys/virtual-printer/spoolbuddy/failure-detection/users/backup), so the URL-param check atSettingsPage.tsx:179(validTabs.includes(tabParam) ? tabParam : 'general') silently fell back to the General tab. The Bambu Cloud login UI actually lives on the Profiles page (/profiles), which already defaults its sub-tab tocloud— the same destination the existingbackup.cloudLoginRequiredi18n string ("Sign in under Profiles → Cloud Profiles…") documents. One-line fix inMakerworldPage.tsx:438:to="/settings?tab=cloud"→to="/profiles". The Profiles page'suseState<ProfileTab>('cloud')(line 2822) means no query param is needed — landing on/profilesopens the Cloud sub-tab directly. -
External-spool prints no longer credit usage to AMS slot 0's Spoolman spool (#1276, reported and diagnosed by @ojimpo — regression of #853) — On a single-filament external-spool print (TPU loaded in
vir_slot id=254on the reporter's H2S + AMS 2 Pro),_resolve_global_tray_idinspoolman_tracking.pywas crediting the usage to whatever Spoolman spool happened to be linked to AMS slot 0 — a completely unrelated material in the reporter's case. ~48.94 g of TPU was credited to a PLA spool across 4 prints before they noticed. Root cause: BambuStudio encodes virtual tray IDs (254/255) as-1in the flatams_mappingarray it sends to the printer (a convention already documented inbambu_mqtt.py:start_print()), but the spoolman tracking helper was treating-1as "unmapped → use position-based default" and the default mappedslot_id=1→global_tray_id=0. Whenslot_to_tray[slot_id-1] == -1andams_trayscontains an external slot (254 or 255), the helper now returns the external tray ID directly, matching the conventionstart_print()uses on the other side of the pipeline. Prefers 254 over 255 (consistent with single-nozzletray_nowreporting and thevir_slotid=255→254 remap inbambu_mqtt.py:864). Legacy behavior preserved whenams_traysis empty or contains no external slot (callers that don't passams_trayskeep the position-based fallback). Two regression tests cover the reporter's exact scenario (ams_trays={0,1,2,3,254}, slot_to_tray=[-1]→ 254) plus the H2D-deputy case and the fall-through-when-no-external case. Root cause investigation and patch by @ojimpo. -
Virtual-printer queue mode now honors workflow default print options (#1235, reported by @jc21, root cause and patch by @jc21 in #1277) — Prints sent from Bambu Studio (or any slicer) to a VP in
print_queuemode arrived in the queue withbed_levelling,flow_cali,vibration_cali,layer_inspect, andtimelapseset to the SQLAlchemy column-level defaults, never the user's workflow preferences. The reporter happened to have every workflow default set to the opposite of the column defaults, so prints appeared to have all five options inverted; every queue item required hand-editing before dispatch. The manualPOST /print-queue/endpoint reads these fields off the request body (the frontend pulls them from settings before submitting), but the VP-FTP-receive path atbackend/app/services/virtual_printer/manager.py:_add_to_print_queueconstructedPrintQueueItemwithout touching them at all — SQLAlchemy then filled inbed_levelling=True, flow_cali=False, vibration_cali=True, layer_inspect=False, timelapse=Falseregardless of what was in the DB. Fix readsdefault_bed_levelling/default_flow_cali/default_vibration_cali/default_layer_inspect/default_timelapsevia the existingget_setting()helper (same pattern already used in the function forvirtual_printer_archive_name_source) and passes them explicitly toPrintQueueItem. A small_bool_setting()helper mapsNone → AppSettings schema default, so a fresh install with no workflow page customization behaves identically to before. Regression tests:test_add_to_print_queue_uses_workflow_defaults_from_settings(verifies all five settings flow through with values opposite to the column defaults, matching the reporter's exact scenario) andtest_add_to_print_queue_falls_back_to_schema_defaults_when_unset(verifies the no-DB-row path). -
Linking a Spoolman spool to an AMS-HT slot no longer fails with a CHECK constraint error (#1274, reported by guillaume.houba) — On H2C / H2D, AMS-HT units report
ams_id128+ (one ams_id per unit, single tray). Thespoolman_slot_assignmentstable'sck_ams_id_rangeconstraint only allowed 0-7 (standard AMS) or 255 (external), so the upsert onPOST /spoolman/inventory/slot-assignmentsblew up withIntegrityError: CHECK constraint failed: ck_ams_id_rangeand the user had no way to link any spool to an AMS-HT slot. Widened the constraint formula to(ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255— matches the value range the internalspool_assignmenttable already accepts and leaves room for up to 64 AMS-HT units (the existingbambu_mqtt/usage-tracker code uses the same 128-based addressing). Updated in the ORM model (models/spoolman_slot_assignment.py) and both the SQLite/PostgresCREATE TABLEDDL incore/database.py. New idempotent migration_migrate_widen_spoolman_slot_ams_id_range: Postgres path runsDROP CONSTRAINT IF EXISTS+ADD CONSTRAINT(no data risk — the new formula is strictly wider than the old); SQLite path detects the stale formula insqlite_master, table-rebuilds via the standard_v2rename pattern used elsewhere in this file (_migrate_update_auto_link_constraintatdatabase.py:418), and leaves pre-constraint legacy tables untouched. Tests:test_ams_id_check_admits_ams_ht_range(ORM + DDL formula) andtest_assign_accepts_ams_ht_id(end-to-endPOST /slot-assignmentswithams_id=128). -
X2D live camera stream no longer cut by Obico polling / snapshot capture (#1271, reported by @clabeuhtegrite) — The MJPEG fan-out broadcaster from #1089 lets multiple browser viewers share one upstream RTSP socket per printer, but internal callers (Obico AI polling at the user's configured
obico_poll_interval, and the manual/camera/snapshotendpoint) still opened their own fresh RTSP connections. X1C / H2D / P2S firmware tolerates brief concurrent camera sockets so the gap was invisible there. X2D firmware01.01.00.00(and likely future firmwares) enforces strict single-camera-connection more aggressively: every Obico poll (default every 5 s) kicked the live stream, the broadcaster paid the multi-second RTSP handshake to reconnect, and the user saw the stream cut "all the time." New helpertry_get_active_buffered_frame(printer_id)atapi/routes/camera.py:74returns the broadcaster's last buffered frame (always <1 s old while any viewer is connected) andNonewhen no viewer is active. Obico's_capture_frameand the/camera/snapshotendpoint check it first and only fall through to a fresh socket when no stream is running — preserving today's behavior when nobody is watching.plate_detectionandlayer_timelapsedeliberately not converted: plate-detection needs guaranteed-fresh frames post-print (false-positive risk if the user already grabbed the print in the same second), and layer-timelapse is for external cameras only. Regression tests:test_camera_snapshot_reuses_buffered_frame_when_stream_activeand twoTestCaptureFrameSharesBroadcasterUpstreamObico tests. -
Usage tracker: spool swaps in UNUSED slots mid-print no longer charge the old spool (#1269, reported by @maugsburger) — Path 2 of the usage tracker (AMS remain% delta fallback) iterated every AMS tray that had a remain% delta, even slots the print never touched. When a user swapped spools in an unrelated slot during a print, the new spool reports
remain=0(no RFID tag yet) while the snapshot from print-start was 100%, so the fallback charged the originally-assigned spool the full 1000 g. Reporter's case: single-filament print on AMS0-T3 (ams_mapping=[3]), swapped a spool in T1 and another in T2 to refill while the print continued — wound up withSpool 27 consumed 1000.0g (100%) on printer 1 AMS0-T1andSpool 24 consumed 170.0g (17%) on printer 1 AMS0-T2, neither of which were ever in the print. Fix: the fallback now buildsprint_used_keysfromsession.ams_mapping,state.tray_change_log, andsession.tray_now_at_start(the three runtime signals telling us which trays were actually part of the print), converts each global tray ID to(ams_id, tray_id)using the standard convention (254/255 → external, ≥128 → AMS-HT, otherwiseid // 4, id % 4), and skips fallback for trays whose key is not in that set. When all three signals are empty (legacy edge case: no slicer push, no MQTT tray-change events, notray_nowat start) the legacy "scan every tray" behavior is preserved so we don't regress prints with no metadata. Regression test intest_usage_tracker.py::test_skips_fallback_for_trays_outside_print_mappingreproduces the reporter's exact scenario. -
Printer card: smart-plug live wattage now rounded to whole watts (#1266, reported by @Carter3DP) — The printer card's smart-plug status badge rendered
plugStatus.energy.powerraw, so plugs that report fractional watts (Kauf PLF12 via ESPHome / Home Assistant in the reporter's case, but any MQTT plug pushing a float can hit this) showed values like14.123456789012W and overflowed the card width.SmartPlugCardandSwitchbarPopoveralready wrapped the same field inMath.round(); only the printer-card badge was missing the round. Single-line fix atfrontend/src/pages/PrintersPage.tsx:4569.
[0.2.4] - 2026-05-11
Added
-
Build-plate icon on archive cards + uniform printer/model line (#1253, reported by @tonygauderman) — Archive cards now show an OrcaSlicer-style bed icon in the printer/model row indicating which build plate the print was sliced for (Cool / Cool SuperTack / Engineering / High Temp / Textured PEI / Smooth PEI), with the full plate name in the hover tooltip. Closes the gap where users had to remember which plate matched a re-print or open the source 3MF in a slicer just to read the bed setting. Card row also unified: archives with a real Bambuddy-printer association used to render as
H2D-1 GCODE …while slicer-only uploads rendered asSliced for X1C GCODE …— same line, two different shapes. Dropped theSliced forprefix so both render as a uniform<name-or-model> [bed-icon] GCODE <hash>row, scanning the same regardless of provenance. Backend: newbed_typecolumn onprint_archives(idempotentALTER TABLEmigration; SQLite + Postgres safe), populated fromcurr_bed_typeinMetadata/slice_info.config(per-plate metadata, the authoritative source — that's the bed type that actually got sent to the printer for the exported plate) with a fallback toMetadata/project_settings.config's top-levelcurr_bed_typefor older 3MF shapes. Wired through both code paths that produce archive responses:archive_to_response()(the hand-rolled dict converter atarchives.py:97— easy to miss, the schema-only change is silently dropped by Pydantic since the route bypassesfrom_attributes) and the/rescanendpoint, so old archives can be re-parsed by the user via the existing per-archive Rescan button. Newly-ingested archives get the value automatically. Backfill script:scripts/backfill_archive_bed_type.py(with--dry-run) re-opens every NULL archive's 3MF on disk and populates the column — opt-in for users who want their entire history covered without waiting for natural turnover. Auto-loads.envfrom project root before importing backend modules (sincecore/config.py:52readsDATABASE_URLfromos.environat import time, not frompydantic-settingsatSettings()time), prints the resolved DB URL with credentials redacted on every run so operators can confirm they're hitting the intended database (Postgres / SQLite — Bambuddy supports both per #1219'sDATABASE_URLpathway), and callsinit_db()itself before querying so the migration applies even if the script is run against a database the backend hasn't touched yet. Frontend: 6 OrcaSlicer-style PNGs ship infrontend/public/img/bed/(under/img/because that path was already statically mounted atmain.py:5244; the/bed-icons/toplevel attempted first hit the SPA catch-all and returnedindex.htmlastext/html, which the browser then rendered nothing for). Newutils/bedType.tsmaps slicer strings (case-insensitive) to icon + human-readable label; covers Bambu Studio and OrcaSlicer's diverging spellings for the same physical plate (e.g.Cool Plate↔PC Plate,Cool Plate (SuperTack)↔Supertack Plate↔Bambu Cool Plate SuperTack). Renders on both card-grid view and list view inArchivesPage.tsx. Unmapped or NULLbed_typesimply omits the icon, so cards stay clean for archives created before this change. Note on icon mapping:bed_pei.png→ Textured PEI,bed_pei_cool.png→ Smooth PEI is a best-guess from the OrcaSlicer asset names — swap the two paths inbedType.tsif a future user reports the icons reversed for their plate. -
Spool labels: new 40×30 mm template, hex colour code, bolder brand line (#809 follow-up, requested by @oliboehm) — Three small enhancements to the spool-label printer rolled into one change. (1) New
box_40x30template — 40×30 mm single label, common DK/Brother roll size. Added to_SINGLE_LABEL_SIZES_MMinbackend/app/services/label_renderer.pyand to the request body'sLiteral[...]enum inbackend/app/api/routes/labels.py; height is ≥ 20 mm so it routes through the existing roomy layout (swatch + QR + full text column). (2) Colour hex code on every label — new_hex_code_label()helper formatsdata.rgbaas#RRGGBB(alpha-stripped, uppercased to match the inventory UI's colour-picker convention) and returns""for missing/malformed input so the caller skips drawing instead of throwing. Rendered as a small line under the material/subtype line in the roomy layout, and as a third line above the spool ID in the tight (AMS) layout — useful when several near-identical material/colour spools sit next to each other in the AMS or on a shelf. (3) Brand line bigger + bold — the brand on every label now renders inHelvetica-Boldinstead ofHelveticaregular, with size bumped 5.5pt → 6.5pt on the tight layout and 7pt → 8pt on the roomy layout, so it's the most legible non-ID field at arm's length. Wiring:SpoolLabelTemplateunion infrontend/src/api/client.tsextended with'box_40x30';LabelTemplatePickerModalgets a newTEMPLATE_OPTIONSentry for it;inventory.labels.templates.box40x30.{label,hint}keys added across all 8 locales (en + de fully translated, fr/it/ja/pt-BR/zh-CN/zh-TW translated to native, with the existing per-key fallback in the modal as a safety net). The 5-template grid still wraps to 2 columns on small viewports per #1230's fix; modal regression test was widened from4to5template buttons. Tests:ALL_TEMPLATESparametrize tuple intest_label_renderer.pyextended withbox_40x30so all 7 generic invariants (PDF header, empty-input, multi-colour, missing-fields, malformed-rgba, long strings, sheet pagination) cover the new template; newtest_hex_color_code_rendered_when_rgba_set(asserts#F5E6D3appears in the uncompressed PDF for both 40×30 and 62×29),test_hex_color_code_skipped_when_rgba_invalid(regex pin: no#RRGGBBshape on the label when rgba is malformed, except the spool ID's#42), andtest_brand_rendered_in_bold_per_809_followup(assertsHelvetica-Boldfont reference is in the PDF — caught a regression if the brand line ever reverts to regular weight). All 33 backend tests + 15 frontend modal tests pass; ruff clean. -
Copy spool — duplicate any spool's settings into a fresh inventory row in two clicks (#1234, PR #1246 by @MiguelAngelLV) — Adds a copy button (
Copyicon) next to the existing edit button on every spool in the inventory page across all three views (table row, card, grouped table inner row). Clicking it opens the existingSpoolFormModalpre-filled with every field from the source spool — material, brand, color, slicer preset, label/core/cost, K-profiles, all of it — exceptweight_usedwhich is reset to 0 (since the new spool starts full) and the RFID identity fields (tag_uid,tray_uuid,tag_type,data_origin) which aren't part of the form payload anyway, so the new spool is its own physical roll. Save callsapi.createSpool(orapi.createSpoolmanInventorySpoolin Spoolman mode — both inherit the dispatch routing for free). Closes the long-running gap where users with many near-identical spools (e.g. five 1 kg PETG-CF rolls bought in a single order) had to re-enter every field from scratch on each one. Implementation shape:SpoolFormModalProps.mode: 'create' | 'edit' | 'copy'(exported asSpoolFormMode) replaces the previousisEditing = !!spoolheuristic — every existing call site inInventoryPage.tsxwas updated to pass the explicit mode, and the modal's title / submit-button label / weight-reset gate / submit-route branching all key onmodedirectly. TheonCopycallback is optional onSpoolCard,SpoolTableRow, andSpoolTableGroup(matches the existingonPrintLabel?pattern), so the button is conditionally rendered and other consumers of those subcomponents don't get a copy affordance forced on them. Card-view and table-row buttons stop click propagation so clicking copy doesn't also fire the parent row's edit handler. Quick Add interaction: the Quick Add toggle is gatedmode === 'create'(was!isEditing), so it stays out of copy mode — otherwise a user could enable Quick Add and bump quantity to N under the singular "Copy Spool" title and silently bulk-create N copies viabulkCreateMutation. i18n: newinventory.copySpoolkey across all 8 locales (en + de translated, fr/it/ja/pt-BR/zh-CN/zh-TW seeded with English fallback per project flow). Tests: 3 new inSpoolFormModal.test.tsx(SpoolFormModal copy modedescribe block — title shows "Copy Spool", save callscreateSpoolnotupdateSpool,weight_usedreset to 0 in the create payload when copying a spool with non-zero usage), 2 new inInventoryPageCopyButton.test.tsx(table-row copy button click → "Copy Spool" heading, cards-view copy button click → same heading after switching view modes) — guards against the three call sites drifting apart. ExistingSpoolFormBulk.test.tsxandSpoolFormModal.test.tsxrenders that omitted themodeprop were updated with the explicitmode="create"so the tightened Quick Add gate doesn't hide the toggle from them. BothInventoryPageCopyButton.test.tsxandInventoryPageDeepLink.test.tsxgained MSW handlers for the modal's open-time fetches (/api/v1/cloud/status,/api/v1/cloud/local-presets,/api/v1/cloud/builtin-filaments,/api/v1/inventory/color-catalog,/api/v1/inventory/spool-catalog,/api/v1/printers/) — without them MSW passes through to the real network, ECONNREFUSEs, and the rejected fetch resolves after the test environment is torn down, surfacing as a flaky "window is not defined" unhandled rejection in the modal'ssetLoadingCloudPresets(false)finally block (pre-existing flake hit ~1 in 3 full-suite runs at PR head).
Fixed
.bbscfgPrinter Preset Bundle import was broken for every user since launch — sidecar compose file pointed at the wrong branch (#1312, reported by @hasmar04, confirmed by @netscout2001) —slicer-api/docker-compose.yml'sbuild.contextpointed athttps://github.com/maziggy/orca-slicer-api.git#bambuddy/profile-resolver, but thePOST /profiles/bundleendpoint plus theuploadBundlemulter middleware were only ever committed to a sibling branchbambuddy/bundle-import(commita3172c5, 2026-05-06). Every user who ran the documenteddocker compose up -dgot a sidecar without the bundle endpoint — theirPOST /profiles/bundlefell through to the genericPOST /profiles/:categoryhandler, which either rejected with "Name cannot be empty" (nonameform field sent) or "Invalid file type. Only JSON files are allowed." (the JSON multer filter rejecting the.bbscfg). Fix:bambuddy/bundle-importfast-forward-merged intobambuddy/profile-resolverin the orca-slicer-api repo and pushed, so the compose file's existing branch ref now points at the right commit. No Bambuddy code change. Existing users rebuild withcd slicer-api/ && docker compose --profile bambu build --no-cache --pull && docker compose --profile bambu up -d—--pullis the key flag because BuildKit caches the git fetch context separately from layer caches, so--no-cachealone silently reuses the old branch checkout. New users on 0.2.5+ are unaffected. Lesson on diagnosis flow: the wrong root cause was reported twice during triage before the actual branch mismatch was caught — first as "build a week ago, before the bundle endpoint existed" (correct claim for the wrong branch), then as "rebuild with --pull" (still hit the same bug because the compose file pointed at the branch that never got the work). The reporter's third round of logs — the multer "Only JSON files are allowed" error string fromupload.js:17, which only matchesuploadJsonnotuploadBundle— was the smoking gun that no amount of rebuilding would help because the wired-up branch genuinely lacked the endpoint.
Changed
- Support bundle records slicer-API CLI versions; wiki sidecar-update docs hardened (#1312 follow-up) — Triage scaffolding added during investigation of the bundle-import bug above. Useful independent of that fix: the next time a user reports a sidecar-related failure, the support bundle will identify which slicer CLI version is actually running without needing a manual
curl /health. Backend: new_fetch_slicer_health(url)helper inbackend/app/api/routes/support.pydoes a 2-second GET on<sidecar>/health, parses the JSON, and walks every non-dataPathkey undercheckslooking for aversionfield — needed because the wrapper labels both bambu-studio-api and orca-slicer-api aschecks.orcaslicerregardless of which CLI is actually bundled (cosmetic wrapper bug, not Bambuddy's)._collect_slicer_api_infonow calls it instead of the bare reachability ping and adds two new fields per side to the integrations block:bambu_studio_version,orcaslicer_version. Captures"unknown"verbatim when the wrapper's--helpregex didn't match (which is itself diagnostic). Behavior preserved on error paths: empty URL returnsNone, connection failure returns{reachable: False, version: None}, malformed/non-200 returns{reachable: True, version: None}so the reviewer can separate network failure from misconfiguration. Trailing-slash in the configured URL is stripped before appending/health. Tests: 9 new inTestFetchSlicerHealth; existingTestCollectSlicerApiInfotests updated to patch_fetch_slicer_healthand assert the new_versionfields. All 62 helper tests pass; ruff clean. Docs:bambuddy-wiki/docs/features/slicer-api.mdgot four additions. (1) Quick Start gains a warning callout that the Compose file builds from a branch tip and a plaindocker compose up -dwill keep using the originally-built image. (2) The Updating section now recommendsdocker compose --profile bambu build --no-cache --pull(both flags) and explains why both matter. (3) New troubleshooting entry for the "Name cannot be empty" / "Only JSON files are allowed".bbscfgimport error. (4) New troubleshooting entry for the orphan-container conflict (container name "/bambu-studio-api" is already in use) that hits users whose existing containers were built from an older compose file with un-prefixed image tags. The pre-existing/health version: "unknown"entry also got a note clarifying that the wrapper mislabels thechecksfield asorcaslicerfor both sidecars — both are cosmetic, not stale-image indicators.
Fixed
-
LDAP settings: "Advanced" collapsible section header was always rendering in English regardless of UI language (#1297, reported by @Fuechslein) —
LDAPSettings.tsx:352callst('settings.ldap.advanced') || 'Advanced', but the translation key was never defined in any locale file. The|| 'Advanced'fallback kicked in and the header rendered as English in every language. Addedsettings.ldap.advancedto all 8 locales:Advanced(en),Erweitert(de),Avancé(fr),Avanzate(it),詳細設定(ja),Avançado(pt-BR),高级(zh-CN),進階(zh-TW). No component change needed — the fallback now never triggers because the key resolves properly. i18n parity check holds at 4754 leaves across all locales. -
Clear Plate button required granting Settings > Read Settings, leaking the entire Settings UI to non-admin users (#1293, reported by @Tivonfeng) — On the Printers page, the "Clear Plate" button is gated on the global
require_plate_clearsetting beingtrue. The page reads that value fromGET /api/v1/settings, which requiresPermission.SETTINGS_READ. A user withprinters:clear_platebut nosettings:readgot a 403 on the settings fetch, the frontend'ssettingsquery stayed undefined,requirePlateClearevaluated tofalse, and the button never rendered. The reporter's workaround — also grantsettings:read— works but also adds the Settings nav item to the sidebar and grants visibility of SMTP/LDAP/MQTT credentials and every other setting in the DB, which is exactly the leak they were trying to avoid. Fix: newGET /api/v1/settings/ui-preferencesendpoint that returns a curated dict of UI rendering fields without requiring SETTINGS_READ — matches the existingGET /settings/default-sidebar-orderprecedent (intentionally unauthenticated for the same reason — UI rendering needs values that aren't admin-gated). Exposed fields are explicitly opt-in via a_UI_PREFERENCE_FIELDStuple inroutes/settings.py:require_plate_clear,check_printer_firmware,camera_view_mode,time_format,date_format,drying_presets,ams_humidity_good,ams_humidity_fair,ams_temp_good,ams_temp_fair,bed_cooled_threshold. Anything not on that list — including every sensitive field — is never returned, no matter what's in the DB. PrintersPage now fetches from/settings/ui-preferencesvia a newapi.getUiPreferences()client method; the cache key changed from['settings']to['ui-preferences']so it doesn't collide with the admin-gated full settings query other admin pages still use. As a side-effect, the page's 4 other settings-driven UI features (drying presets, camera view mode, time format display, firmware-check banner) also stop silently degrading for non-admin users — they all live on the same fetch. Regression tests inbackend/tests/integration/test_settings_ui_preferences.pypin: endpoint returns 200 without SETTINGS_READ, response includesrequire_plate_clearas a bool, field set exactly matches_UI_PREFERENCE_FIELDS(so accidentally adding a sensitive field there fails the test), and a "secret canary" test that seeds 23 sensitive keys with recognizable values and asserts none of them appear in either the response keys or the response body. Frontend types inclient.tstightencamera_view_modeandtime_formatto the same literal unions asAppSettingsso the new endpoint slots into PrinterCard's prop types without casts. -
LDAP user logins wiped manually-assigned BamBuddy groups (#1292, reported by @Fuechslein) — When an admin assigned an LDAP-authenticated user a BamBuddy group that wasn't mapped from LDAP (e.g. "Administrators" while the LDAP mapping only covered "Users"), the assignment vanished on the user's next login. The reporter's observation matched the code exactly: assigning a group while the user was logged in held until the next login because
user.groupswas just mutated in memory; on next login,_sync_ldap_userinbackend/app/api/routes/auth.py:1187rebuiltuser.groupsfrom LDAP state alone and blew away the manual assignment. The design intent (LDAP truth must propagate, including revocation) was correct, but the implementation was over-broad — every BamBuddy group got wiped, not just LDAP-mapped ones. Fix:_sync_ldap_usernow computes the set of "LDAP-managed" BamBuddy group names = values ofldap_group_mapping∪{ldap_default_group}. Groups inside that set are still rebuilt from LDAP truth on each login (so revocation works). Groups outside that set are treated as manual admin assignments and preserved. The partition happens via a list comprehension overuser.groups; no schema or DDL change. Edge case explicitly tested: a manual assignment to a group that IS in the LDAP mapping is still overridden by LDAP state — once an assignment is in the user_groups table you can't tell manual-but-mapped from LDAP-derived, so LDAP wins for any group it has authority over. Regression tests inbackend/tests/integration/test_ldap_group_sync.pycover: manual group survives login (the reporter's exact scenario), revocation still propagates for LDAP-managed groups, default_group persists across empty-LDAP logins, manual assignment to a managed group is overridden, and the realistic mixed case where a user has multiple manual + multiple LDAP groups at once. -
Internal inventory:
storage_locationfield was silently dropped on save and never shown in the table (#1291, reported by @needo37) — Thestorage_locationcolumn existed on the Spool ORM model (backend/app/models/spool.py:57) but was missing from the Pydantic schemas inbackend/app/schemas/spool.py(SpoolBase,SpoolUpdate, and by extensionSpoolResponse). Pydantic silently strips unknown fields, so PATCH writes to/inventory/spools/{id}reached the update route'smodel_dump(exclude_unset=True)already missing the field, thesetattrloop never touched the DB column, and GET responses left it out — the inventory table always showed "—" in the Storage Location column even when the user had typed and saved a value. Only the internal inventory was affected; Spoolman mode worked because it goes through a separate proxy backend with its own schema. Fix is two added fields inschemas/spool.py: one onSpoolBase(coversSpoolCreate+SpoolResponsevia inheritance) and one onSpoolUpdate(standalone). Both constrained tomax_length=255to match the DB column'sString(255). No route changes needed — the update handler atinventory.py:961already uses the generic dump-then-setattr pattern that picks up any new schema field automatically. Note on UX intent:storage_locationis the user-defined free-text label ("Drybox #1", "Top shelf"), distinct fromlocationwhich is the AMS slot assignment ("AMS-A slot 3") — keeping both is the right call. Regression tests intest_spool_schemas_storage_location.pylock in: create/update accept the field, the response surfaces it, explicit-null clears viaexclude_unsetround-trip, omitted-on-PATCH is left untouched (doesn't accidentally clear), andmax_length=255is enforced (so the API returns a clean 422 instead of a SQLAlchemy column-length error). -
Archives page didn't auto-refresh when a slicer sent a print to a Virtual Printer — the new card only appeared after switching tabs (#1282, reported by @kleinwareio) — Real-printer prints broadcast
archive_createdover the WebSocket frommain.py's MQTTprint_starthandler, and the Archives page listens for that event infrontend/src/hooks/useWebSocket.ts:241to invalidate its react-query cache. The VP file-receive paths inbackend/app/services/virtual_printer/manager.py(_archive_filefor immediate mode and_add_to_print_queuefor queue mode) created the archive and committed it to the DB but never broadcast the event — so the page stayed stale until the user clicked another tab and back, which triggered a refetch on focus. Fix: factored a small_broadcast_archive_created(archive)helper ontoVirtualPrinterInstancethat importsws_managerlazily (matches the file's existing late-import convention for archive/queue imports) and emits the same{id, printer_id, filename, print_name, status}payload shapemain.pyuses. Called from both VP paths immediately after the archive is logged (_archive_file) and after the queue item is committed (_add_to_print_queue). Broadcast failures are swallowed at debug level so a transient WebSocket issue can't break the file-receive flow. The review mode path (_queue_file) is intentionally untouched — it creates aPendingUpload, not aPrintArchive, and renders on a different page. Tests:test_archive_file_broadcasts_archive_createdandtest_add_to_print_queue_broadcasts_archive_createdpatchws_manager.send_archive_createdand assert it's called once with the right payload shape. Affects: every Bambuddy install using a VP inimmediateorprint_queuemode; review mode and proxy mode are unaffected. -
Virtual Printer wedged the slicer at "Downloading...(0%)" when a user clicked Print (instead of Send) against a non-proxy-mode VP, and blocked the next dispatch with "The printer is busy with another print job" (#1280, reported by @kleinwareio) — Bambuddy's VP supports two distinct dispatch flows from the slicer: Send (file upload only — the path queue / immediate / review modes are designed for) and Print (file upload + start-print, intended for proxy mode where there's a real printer behind the VP). The reporter's setup was queue mode but they clicked Print, which is unsupported there. The user-facing symptom was wedging instead of a clean error: the FTP upload completed, the file landed in Bambuddy's queue, but Orca's UI froze at
Downloading...(0%)and the next attempt was blocked. Cause: the VP's simulated state machine, inbackend/app/services/virtual_printer/manager.py::on_file_received, jumpedPREPARE → IDLEdirectly after the FTP upload completed. The Send flow doesn't watch the post-upload state, so Send users never noticed. The Print flow watches the gcode_state cycle expectingPREPARE → RUNNING → FINISHand only releases its in-flight-job lock when it seesFINISH(orFAILED). GoingPREPARE → IDLElooks to the Print-flow slicer like "printer abandoned my job without confirming completion" → UI keeps the prior job pinned → next dispatch is blocked.gcode_file_prepare_percentalso stayed at"0"for the whole upload window, which is why Orca's "Downloading X%" progress bar never advanced. Fix:on_file_receivednow transitionsPREPARE → FINISHwithprepare_percent="100"and the just-completed filename. The VP's 1-Hz periodic status push (mqtt_server.py:363) broadcasts the new state to every connected slicer within a second, so Orca clears its lock and the next dispatch goes through. The transition is gated to.3mfuploads only — auxiliary uploads (printer-side.gcodeblobs etc.) leave the visible state alone. Treats Print and Send identically in non-proxy modes — Print is now silently handled as "file received, treat as completed" instead of wedging the slicer. Send remains a no-op behavior change because Send doesn't watch the post-upload state. Tests: 2 new tests inbackend/tests/unit/services/test_virtual_printer.pypin (1) the FINISH transition with the correct filename + prepare_percent="100", and (2) the non-3MF guard. Affects every VP mode that isn't proxy (immediate,print_queue,review) on every slicer using the Print flow (BambuStudio + OrcaSlicer in LAN-mode). -
External-spool filament selection silently rolled back: every "Generic PLA" / preset change for the external slot looked applied in the UI but failed on the printer, and the next print threw "no mapping" (#1279, reported by @kleinwareio) — Repro: P1S, no AMS, vt_tray active. User picks any filament for the external slot via Bambuddy. The UI looked normal, but the printer's MQTT response was
{"command":"ams_filament_setting", "result":"fail", "reason":"error string"}. The companionextrusion_cali_selcommand succeeded, so the K-profile stuck but the filament identity didn't — and the next print therefore had nothing to map to. Cause:backend/app/services/bambu_mqtt.py::ams_set_filament_settingencoded the single-external-spool case as{ams_id: 255, tray_id: 0, slot_id: 0}. The "LOCALtray_id = 0" comment in the code was a misread of the printer's response shape (the printer echoestray_id: 0as the slot-within-virtual-unit, not the slot index used in the request). Verification: captured BambuStudio → X1Cams_filament_settingpublish viamosquitto-compatible paho-mqtt subscriber on the same broker, BambuStudio set the external slot to a PLA preset, the published REQ was{ams_id: 255, tray_id: 254, slot_id: 0, tray_info_idx: "P4d64437", tray_color: "F72323FF", tray_type: "PLA", ...}and the printer's REP returnedresult: "success". The on-wire convention forams_filament_settingon the external spool is therefore the global tray index (tray_id: 254), not a local slot number (tray_id: 0). Fix:mqtt_tray_id = 254for the single-external branch in bothams_set_filament_settingandreset_ams_slot(which shares the convention). The dual-external branch (H2D,len(vt_tray) > 1) was not in the captured exchange and is left atmqtt_tray_id = 0until a Studio → H2D capture confirms the correct value — a regression test pins the current dual-external encoding so any future change to that branch surfaces immediately. Affected printers: every printer whose MQTT push reportsvt_trayas a single-element list — i.e. one external slot. That covers all single-nozzle Bambu printers (P1P, P1S, A1, A1 mini, X1C, X1E) plus dual-nozzle models that use a single external feed (X2D). Not affected by this change: H2D / H2C / H2S, which expose two external slots and go through a separatelen(vt_tray) > 1branch. That branch is preserved at its existingmqtt_tray_id = 0encoding because the captured exchange did not cover it; if the same misencoding turns out to affect dual-external too, a Studio → H2D capture will surface the right values and a follow-up patch will land. Known asymmetry not touched in this PR: the inlineams_filament_settingbuilt by_probe_developer_mode(bambu_mqtt.py:2971-2985) still hardcodestray_id=0. The probe is robust to this — its detection logic only matchesreason: "verify failed"so it correctly identifies dev-mode regardless of whether the command itself succeeds — but the two builders should be unified in a follow-up. Tests: 5 new tests inbackend/tests/unit/services/test_bambu_mqtt.py::TestAmsFilamentSettingExternalSpoolEncodingpin the X1C/P1S/A1 single-external fix,reset_ams_slotsymmetry, regular AMS slot encoding unchanged, AMS-HT slot encoding unchanged, and the explicitly-unverified dual-external encoding (so any future change to the dual branch surfaces in diff review). -
Scan For Timelapse matched the wrong video when an older print's filename happened to land near a later archive's completion (#1278, reported by @1000Delta) — Repro: P2S in LAN-Only mode (no NTP, so printer clock is drifted +8h from UTC), two prints on the same day. Archive 1 correctly attached
video_2026-05-08_09-41-29.mp4. Archive 2 (started at 16:39:09 UTC, expectedvideo_2026-05-09_00-42-42.mp4) reused Archive 1's video with a misleadingdiff: 0:02:19. Cause:scan_timelapse's Strategy 2 matcher inbackend/app/api/routes/archives.pyhad two compounding flaws. (1) It compared the filename timestamp against botharchive.started_atandarchive.completed_atwith a 48 h tolerance — but the filename always represents the print's START time, never its end, so the end-time branch was a semantic mistake whose only effect was creating false positives. For Archive 2, the stale filename09:41:29shifted by hypothesis offset-8h→17:41:29, which happened to fall ~2 minutes before Archive 2's completion → "diff" 2m19s won. (2) The matcher tried seven hypothesised offsets[0, ±1, ±7, ±8], which densely covers a wide span of the day. Even with the end-time branch removed, the wrong video at offset-7lands at16:41:29→ 2m20s from Archive 2's start, beating the correct video's 3m33s at offset+8. Fix: extracted Strategy 2 into a pure_match_timelapse_by_timestamp(video_files, archive_start)helper that (a) only compares against print start time (end-time evidence is handled separately by Strategy 3 via file mtime, which actually does reflect when writing finished), and (b) requires the best (video, offset) pair to beat the next-best pair from a different video by at least 15 minutes. When the top two candidates from different videos are too close to call, the helper returnsNoneso the route surfaces the existingavailable_fileslist and the frontend's manual-selection dialog kicks in — which is the fallback the reporter explicitly asked for ("at a minimum, we should support that can fall back to letting the user manually select"). Wide offset support is preserved so EU / JST / AEST users (offsets +1, +7, +9, +10, etc.) still get auto-match when there's no ambiguity. Tests: 17 new tests inbackend/tests/unit/test_timelapse_match.pypin the bug case (test_issue_1278_archive2_refuses_to_auto_pick_ambiguous,test_issue_1278_archive1_still_matches_unambiguously), the resolution path once the stale video is cleaned up (test_archive2_resolves_when_stale_video_removed), each of the 7 supported offsets via parametrize, and the supporting invariants (nostarted_at→None, non-timestamp filenames are skipped, same-video different-offset is not ambiguous, well-separated different videos still auto-pick). Known UX gap not in this PR: if the matcher auto-picks a wrong match, the user must delete the attached timelapse first before re-scanning —scan_timelapseshort-circuits withstatus: "exists"whentimelapse_pathis already set. Adding a force-rescan or "wrong match, pick from candidates" affordance is a separate change. -
Docker image: pip upgraded to >=26.1 to close CVE-2026-6357 (medium) — The
python:3.13-slim-trixiebase image ships pip 26.0.1, which runs its self-update check after installing wheels. A hostile wheel that included a module named like a deferred stdlib import (urllib,ssl, …) could therefore hijack imports inside the just-finished install step. The exploit path is theoretical for Bambuddy itself — we don't install user-supplied wheels at runtime — but the vulnerable pip version still ships inside the image, GitHub code-scanning flagged it (alert #778), and any downstream user whopip installs into the running container inherits the issue. Fix: Dockerfile now runspip install --upgrade 'pip>=26.1'immediately beforepip install -r requirements.txt, so the requirements install itself happens under the patched pip and the resultingpip-*.dist-info/METADATATrivy reads from the layer is the fixed version. Norequirements.txtchange — the floor is enforced at the image-build layer where the vulnerable copy lived. (libexpat1 alert #795 also flagged by code-scanning is a DoS-only XML attribute-collision CVE with no patched Debian trixie package yet — left open as a tracking signal; next base-image rebuild after trixie ships libexpat 2.8.1 will close it automatically.) -
Gitea backups silently failed after the first run; Forgejo v15 token-scope quirk broke "Test Connection"; many failure paths surfaced cryptic one-word errors (#1224 reported by @rtadams89, #1239 + PR #1255 by @BurntOutHylian) — Two intertwined problem clusters on the Git-backup path, fixed as one PR. (1) Gitea backups quietly stopped after run #1. The Git backup service used GitHub's Git Data API (
POST /git/blobs→/trees→/commits→PATCH /refs) for every push. Gitea does not implement these write endpoints on modern versions, so every blob POST returned 404; the loop'scontinue-on-non-201 pattern left the change list empty and the route returned{"status": "skipped"}instead of committing — no toast, no log row, just "no changes" forever. The first run only worked because the empty-repo path already used the Contents API. Fix:GiteaBackend.push_filesis overridden to usePOST /repos/{owner}/{repo}/contentswith afilesarray — every changed file is sent asoperation: "update"(with its current blob SHA) oroperation: "create", the whole batch commits in a single round-trip, no partial-commit failure mode possible._create_branch_and_pushswitched from the unimplementedPOST /git/refstoPOST /brancheswith{new_branch_name, old_ref_name}. (2) Forgejo v15+ returns 404 (not 403) for private repos when the token lacks repository scope, indistinguishable on the wire from "repo not found / token typo" — Test Connection's existing 404 branch said "Repository not found", which sent users chasing the wrong cause. Fix: newForgejoBackend(inheritsGiteaBackend) overridestest_connectionto GET/userfirst; 401 = bad token, 403 = zero-scope token ("read:user scope missing"), 404 on the subsequent/repos/call surfaces the v15-specific "private repo with scope mismatch" hint instead of the generic message. Hardening pass on the broader backup stack (B18–B26 review round): everyresponse.json()[...]indexing ingithub.py(9 sites: ref/commit/blob/tree/commit/ref acrosspush_files+_create_branch_and_push+_create_initial_commit) now routes through a newbase.py::_read_sha(response, *path)helper that returns(sha, error_reason)— a malformed body no longer bubblesKeyError('object')through the catch-all to surface as the cryptic one-word string"'object'"inlast_backup_message. Tree-fetch failures (GitHub side, mirroring the Gitea side) now returnfailedwith status code + truncated body instead of lettingexisting_filessilently stay empty (which forced every file to re-upload and produced a downstream 422 with no hint at the real cause). GitHub's_create_branch_and_pushfailure message includes the HTTP status code (an empty-body 422 now produces a diagnostic message instead of"Failed to create branch: "). Both backends detecttruncated: trueon the tree-listing response (GitHub's tree API truncates at >7MB / >100k entries) and fail loudly asking the operator to rotate the backup repo — previously a truncated listing made the SHA-equality dedup miss and silently re-uploaded every file each run.test_connectionfailure messages now includestr(e)[:200]alongside the exception class name, so the UI surfaces"Connection failed: ConnectError: certificate verify failed: hostname mismatch"instead of just"ConnectError". Gitea's 409-on-/contentsmessage was softened from "stale blob SHAs" (one possible cause) to "the branch likely advanced concurrently (web-UI edit, another backup run, or path-vs-tree collision)". Every status-code branch ingithub.pyandgitea.pymid-push now emits alogger.warningwith owner/repo context (previously only the outerexceptlogged, so a 403/404/422 left a DB row with no application-log entry). Recursivepush_filesre-entry after branch create now logs"Re-entering push_files after branch create owner/repo -> branch"at info level so replication-lag second-pass failures are debuggable. Tests: +17 new unit tests intest_git_providers.pycovering the GitHub robustness paths (tree-fetch failure, truncated tree, malformed JSON for ref/commit/blob, 403/422 on_create_branch_and_push), the Gitea round-2 hardening (truncated tree, status code inget_current_commit/extract_tree_SHA/get_repo_infofailures, log marker emission), and the Forgejo connection-failure detail. Existing 86 → 103 tests, all pass; full backend suite + integration backup tests green; ruff clean. Tested by @BurntOutHylian against Gitea 1.24.7 / 1.25.4 / 1.26.1 and Forgejo v11 / v15 LTS. Companion wiki update at maziggy/bambuddy-wiki#28. -
Printer card's "Show on Printer Card" smart-plug button toggled power without confirmation (#1260, reported by @thkl) — Smart plugs with the "Show on Printer Card" option enabled appear as a clickable chip in the printer card's HA-entities row (below the main Smart Plug controls). One click cut power to the printer instantly — including mid-print — even though the main Off button next to it already routes through a
ConfirmModaland shows an additional running-print warning. Fix: the HA-row click handler infrontend/src/pages/PrintersPage.tsxnow branches on entity type —script.*entities keep firing instantly (a script is a fire-once trigger, not a power switch, and the existing semantic of "Run" matches user expectation), but switch/light/anything-else entities now open a newConfirmModalfirst. The modal reuses the samevariant="danger"+ running-print warning shape as the existing power-off confirmation: whenstatus?.state === 'RUNNING'it shows the "WARNING: is currently printing! Toggling may cut power and interrupt the print" copy, and renders the default-variant "Toggle the Home Assistant entity ?" message otherwise. The entity name comes fromha_entity_id(withnamefallback) so the modal disambiguates which of multiple plugs the click was on. i18n: newprinters.confirm.{haToggleTitle, haToggleMessage, haToggleWarning, haToggleButton}keys added across all 8 locales (en + de + fr + it + ja + pt-BR + zh-CN + zh-TW translated to native, no English-fallback seeding). Full PrintersPage frontend suite (49 tests) still passes; build clean. -
X2D / H2D dual-nozzle without AMS: filament mapping reported "Required filament type not found in printer" even when the spools were physically loaded (#1257) — Repro: X2D with 0 AMS units, two external spools (Ext-L feeding left extruder, Ext-R feeding right), print job specifies
nozzle_idper filament. The Schedule Print modal showed the orange "Filament Mapping (Type not found)" header and a forced manual slot picker, even though the matching PETG was sitting right there in the external spool holder. Cause:frontend/src/hooks/useFilamentMapping.ts:18-19derived dual-nozzle status solely fromprinterStatus.ams_extruder_mapbeing non-empty. That map is populated from AMS units' info bits, so a dual-nozzle printer with zero AMS units gets an empty map →hasDualNozzle = false→ external spools'extruderIdfalls through toundefined(line 64 ternary fallback). The downstream nozzle-aware filter at lines 117 / 377 (available.filter((f) => f.extruderId === req.nozzle_id)) then rejected every loaded filament becauseundefined !== 0/1for any non-nullnozzle_id. The PETG was loaded, just incorrectly stripped from the candidate set during matching. Fix: widen the dual-nozzle inference to three independent signals OR'd together: (1)nozzles[1].nozzle_diameterpopulated — the most direct signal, set bybambu_mqtt.py:2619-2621only when the printer reports aright_nozzle_diameterMQTT field, so a populated value always implies real second-nozzle hardware; (2)ams_extruder_mapnon-empty — preserved as fallback for the dual-nozzle-with-AMS case the original code already handled; (3)vt_tray.length > 1— single-nozzle printers (P1S / A1 / X1C) only have one external feed, so multiple external trays only exist on dual-nozzle hardware. The first signal alone is not sufficient because the backendstate.nozzlesdefaults to a 2-entry list with emptyNozzleInfo()stubs (bambu_mqtt.py:160) on every printer, single-nozzle included —nozzles.lengthwould always be 2 on the wire and would have regressed every single-nozzle install. Affects all dual-nozzle printers running without AMS: X2D, H2D, X2 Pro. Tests: two new regressions insrc/__tests__/hooks/useFilamentMapping.test.ts.matches external spools per-extruder on dual-nozzle without AMSpins the bug fix — asserts each external spool gets the correctextruderId(1 for Ext-L id=254, 0 for Ext-R id=255) andcomputeAmsMappingpicks Ext-L for a left-nozzle requirement.does not fabricate extruderId for single-nozzle with stub nozzles[1]is the matching guard — asserts that a P1S / A1 / X1C-shape PrinterStatus (with the default-stub second nozzle entry the backend always emits) does NOT trip the dual-nozzle inference, so single-nozzle external spools keepextruderId=undefinedexactly as they did pre-fix. Together they pin both directions: a future change that re-breaks the X2D path fails CI, and one that mistakenly turns single-nozzle printers into dual-nozzle also fails CI. Full frontend suite (1891 tests across 138 files) green. -
GCode Viewer had no in-app way to navigate back — the only exit was the browser's back button — Opening the GCode Viewer from a File Manager card or an Archive card calls
navigate('/gcode-viewer?archive=…' | '?library_file=…'), which mountsGCodeViewerPageas a full-height iframe inside the Layout shell. The page rendered nothing but the iframe, so once the third-party viewer's UI took over the content area there was no in-app affordance to return to the originating list — only the browser's back button. Reported by @maziggy. Fix: added a thin back bar above the iframe infrontend/src/pages/GCodeViewerPage.tsxwith anArrowLefticon button. The button label adapts to the entry point —Back to Print Archiveswhen the URL carries?archive=,Back to File Managerwhen it carries?library_file=, genericBackotherwise (covers the rare deep-link / shared-URL case). Click prefersnavigate(-1)so the user lands back in their original list with scroll position and filters preserved; falls back to/archivesor/fileswhen the page was opened in a fresh tab and there's no SPA history to return to. Iframe height is nowflex: 1inside a flex column under the bar instead of a hard-codedcalc(100vh - 3.5rem)— the layout's existing fixed-header offset is unchanged, only the back bar (~36 px) is subtracted from the viewer's vertical real estate. i18n: newgcodeViewer.{back,backToArchives,backToFiles}namespace added to all 8 locales (en + de fully translated, fr/it/ja/pt-BR/zh-CN/zh-TW translated to native using each locale's existing page-title vocabulary —Druckarchiv/Dateimanager,Archives d'impression/Gestionnaire de fichiers,Archivi di stampa/Gestore file,印刷アーカイブ/ファイル管理,Arquivos de impressão/Gerenciador de arquivos,打印归档/文件管理器,列印歸檔/檔案管理器). -
Archives card's "Reprint" / "Schedule" / "Slice" button labels truncated to "Re..." / "Sc..." on narrow browser windows (#1249) — The action row on each archive card has six buttons: two labelled (Reprint + Schedule, or Slice when the file isn't sliced yet) plus four icon-only utilities (open in slicer, external link, globe, download, trash). The labelled buttons used
flex-1to share whatever space remained after the four fixed-width icon buttons, with the label rendered as<span className="hidden sm:inline truncate">...</span>— i.e. visible at any viewport ≥ 640px, withtruncateellipsizing when there isn't room. The Tailwind viewport breakpoint can't see the card width. The page's grid grows column count alongside viewport (md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4), so cards stay roughly 320–380 px wide across breakpoints and the leftover ~30 px in each labelled button isn't enough for "Reprint", which lands on screen as "Re..." — repro'd from a small browser window in the reporter's case. Fix: breakpoint bumped fromhidden sm:inline→hidden xl:inlineon all three labelled buttons (Reprint at line 1106, Schedule at line 1117, Slice at line 1153 offrontend/src/pages/ArchivesPage.tsx). Labels now appear only at viewport ≥ 1280px where the cards (3-4 columns of ~320 px) actually have headroom for them; on narrow windows the buttons render icon-only with their existingtitle=tooltip kept intact for hover and assistive-tech disclosure. Trade-off accepted: a wide-viewport-with-wide-sidebar setup that compresses the card to under ~320px will still see the truncation, but that's a corner case — the common "small browser window" path is fixed without restructuring the row. -
Spool form's "Slicer Preset" dropdown silently dropped Local Profiles when Bambu Cloud was connected, and collapsed per-printer/per-nozzle variants of cloud and local presets into a single entry (#1248, reported by @andretietz) — Two distinct defects in the same code path. Defect 1 (the reported bug):
buildFilamentOptionsinfrontend/src/components/spool-form/utils.tswas precedence-based —if (cloudPresets.length > 0)returned the cloud list and never reached the local-presets branch, so any Local Profile imported via Profiles → Local Profiles was silently invisible whenever the user was logged into Bambu Cloud (the same profile rendered fine with a greenLocalbadge in the AMS Slot configuration modal). The wiki documents the dropdown as "merged and deduplicated" across cloud + local + built-in. Defect 2 (surfaced during fix verification): the spool form was collapsing all@Bambu Lab P1S 0.4 nozzle/@Bambu Lab X1C 0.4 nozzle/@Bambu Lab A1 0.4 nozzlevariants of "Bambu PLA Basic" into a single dropdown entry by stripping the@printersuffix and dedup'ing by base name (one Map.set per family for cloud defaults, one per family for local presets). The AMS Slot modal lists each variant individually and filters by the active printer model, so the user observed strictly more entries in the AMS Slot than in the Add Spool modal even after the merge fix. The right semantic for the spool form — printer-agnostic by design, since a spool isn't bound to a printer — is to show every variant as its own row, exactly as if you'd summed the AMS Slot's per-printer-filtered output across all printers. Fix: rewrotebuildFilamentOptionsto (a) actually merge all three sources, dropping the precedence early-return, and (b) push each cloudsetting_idand eachLocalPresetrow as its ownFilamentOptioninstead of collapsing byname.replace(/@.*$/, '').displayNamenow keeps the full@printer 0.4 nozzlesuffix so users can pick the right variant. Built-in dedup against cloud setting_id is preserved (mirrorsConfigureAmsSlotModal.tsx:498exactly). Wiredapi.getBuiltinFilaments()into both callers —SpoolFormModalandSpoolBuddyWriteTagPage. Persistence safety: the savedslicer_filamentshape is unchanged — cloud picks still persist theirsetting_id, local picks still persistpreset.filament_type || String(preset.id)(consumed bybackend/app/utils/filament_ids.py::normalize_slicer_filamentwhich expectsGFL05/GFSL05shapes; persisting the bare LocalPreset row id would break slicing). Local-presetallCodesnow carries both thefilament_typeform and theString(preset.id)form sofindPresetOptionresolves both old (pre-fix) and new picks. React-key collision: with collapse removed, two LocalPreset rows can share the samecodeif they sharefilament_type; the dropdown key inFilamentSection.tsxis now composed${option.code}::${option.name}to stay unique. Tests: newfrontend/src/__tests__/components/spool-form/buildFilamentOptions.test.tswith 9 cases — the #1248 regression case, "one entry per cloud setting_id, no @printer collapse", "list each local preset individually", "@printer suffix preserved in displayName", localallCodescarrying both shapes, theGFA00↔GFSA00built-in dedup, the all-empty fallback, and the alphabetical sort. The two existingvi.mock('../../api/client')blocks inSpoolFormModal.test.tsxandSpoolFormBulk.test.tsxwere updated with the newgetBuiltinFilamentsstub. -
SpoolBuddy install.sh re-run failed with
Permission deniedon root-owned files in update mode —download_spoolbuddy()rangit fetch + git checkout + git reset --hardbefore the post-install chown at the end of the function. If a previous install left stray root-owned files in the tree (e.g.static/assets/*written by an earliersudorun, or a frontend build that wrote as root), thegit reset --hardstep aborted with EACCES on the unlink/replace step before reaching the chown. The script then exited and the kiosk's underlying ownership problem persisted, so the next attempt would fail the same way. Fix: pre-emptivelychown -R spoolbuddy:spoolbuddy "$INSTALL_PATH"in the update branch before any git operation runs. The script already runs as root (enforced bycheck_root), so the chown is always safe. The existing post-install chown at the end stays — it now mostly catches new files created during this run that need their ownership normalised. Same root cause showed up on the kiosk's runtime SSH update path (Bambuddy → kiosk:git checkout dev && git reset --hard origin/devrunning as thespoolbuddyuser) but that path can'tchownwithout sudoers expansion — the install.sh fix is the immediate recovery, and re-running the install script restores a clean ownership baseline that the runtime updater can keep healthy thereafter. -
SpoolBuddy SSH update aborted with
TypeError: startswith first arg must be bytes or a tuple of bytes, not strafter the host-key store succeeded —perform_ssh_updatecallsasyncssh.import_known_hosts(...)to materialise anSSHKnownHostsobject for_run_ssh_command'sknown_hosts=keyword arg. Both call sites (the stored-key path at line 221 and the just-stored TOFU re-parse at line 272) passedf"{ip} {key}\n".encode()— i.e.bytes. asyncssh's parser does line-based string operations (line.startswith('#')with astrliteral), so anybytesinput crashes inside its loader withTypeError. The twotry/exceptclauses caught only(ValueError, asyncssh.Error), missingTypeError, so the crash bubbled up and aborted the whole update right after the schema fix successfully persisted the host key. Fix: drop the.encode()at both call sites — pass the str directly. Widened both except clauses to(ValueError, TypeError, asyncssh.Error)so any future asyncssh API surprise degrades to the existing fallback (TOFU mode without host-key verification, with a logger.warning) instead of crashing the update. Existing SSH tests all mockedasyncssh.import_known_hostsitself so they never reached the parser — addedtest_perform_ssh_update_passes_str_not_bytes_to_import_known_hoststo capture both call sites' arguments and assertisinstance(arg, str)so re-introducing.encode()fails CI immediately. -
SpoolBuddy SSH update crashed on Postgres with
value too long for type character varying(500)when storing the device's RSA host key —spoolbuddy_devices.ssh_host_keywas declared asString(500), which is fine for SQLite (ignores VARCHAR length) and for ed25519 host keys (~120 chars), but RSA host keys in OpenSSH format are typically 370 chars (2048-bit) → 544 chars (3072-bit) → ~720 chars (4096-bit). Postgres enforces the limit strictly, so any kiosk reporting an RSA-3072 or larger host key on the first SSH update aborted at theUPDATE spoolbuddy_devices SET ssh_host_key=...flush — thegit fetch + pip install + systemctl restartmay have run successfully but the persistence of the TOFU host key failed and the device's update_status was never written. Fix: widenedssh_host_keyfromString(500)→Texton the model, plus an idempotentALTER TABLE spoolbuddy_devices ALTER COLUMN ssh_host_key TYPE TEXTmigration gated onnot is_sqlite()(Postgres-only; SQLite is a no-op since it doesn't enforce VARCHAR length). Existing rows are preserved —TYPE TEXTis a metadata-only change on Postgres forVARCHAR(N)→TEXTso it's a fast migration even on populated tables. Originally introduced in the H1 SSH-host-key TOFU security fix; the 500-char floor was a guess based on ed25519 sizes that the RSA case immediately blew past. -
SpoolBuddy kiosk Settings → Update button returned "API keys cannot be used for administrative operations" — Same root cause as the four QuickMenu System buttons fixed in 0.2.4b3 (Restart Daemon / Restart Browser / Reboot / Shutdown), missed in that audit. The
POST /spoolbuddy/devices/{id}/updateroute (kiosk's own Settings → Update Daemon button → SSH update on the kiosk device) was gated onPermission.SETTINGS_UPDATE, butSETTINGS_UPDATEis on the API-key deny-list (_APIKEY_DENIED_PERMISSIONSinbackend/app/core/auth.py, introduced in PR #1241). Every kiosk-side request to update the daemon — regardless of the API key's scope set (Read / Print Queue / Control / Legacy) — tripped the deny-list and returned a hard 403 with that message. The 0.2.4b3 fix explicitly carved /update out with the reasoning "replaces the daemon binary, different threat surface" — but that reasoning was wrong:restart_daemonalready replaces the running daemon process, so daemon-replacement is not a step up in blast radius. The SSH update is also strictly scoped to the single device the operator physically controls (git fetch + pip install + systemctl restarton that one host) — same threat profile as the system commands already running onINVENTORY_UPDATE. Fix: lower/spoolbuddy/devices/{id}/updatefromPermission.SETTINGS_UPDATE→Permission.INVENTORY_UPDATE, matching the rest of the kiosk-scoped routes (calibration/tare,display,cancel-write,system/command,system/command-result,update-status). The main Bambuddy in-app updater atPOST /api/v1/updates/applykeepsSETTINGS_UPDATE— that one operates on the Bambuddy host and is correctly fenced behind the deny-list. Tests:test_trigger_update_requires_settings_update(which pinned the broken behavior — 403 on inventory-only key) is renamed totest_trigger_update_accepts_inventory_updateand now asserts the inventory-only key reaches the device-state check (409 offline) instead of 403, so a future re-tightening of the gate surfaces immediately. Class-level docstring intest_settings_api_key_scrubbing.pyupdated to reflect the corrected threat-model reasoning. -
Printer file download 500'd on non-ASCII filenames; same crash latent in three sibling endpoints (#1245, reported by @1000Delta) —
GET /api/v1/printers/{id}/files/download?path=...raisedUnicodeEncodeError: 'latin-1' codec can't encode characters in position …for any path whose filename carried non-ASCII characters (Chinese, Japanese, Arabic, accented Latin), reproducible against P2S firmware on macOS but not target-specific. Cause: the route shovedfilenamestraight intoContent-Disposition: attachment; filename="{filename}"— Starlette/uvicorn encodes response headers as latin-1, so anything outside U+0000..U+00FF crashed at write-time. Same pattern existed in three sibling endpoints reachable with user-controlled non-ASCII input:GET /archives/{id}/qr(usesarchive.print_namefrom 3MF metadata, often non-ASCII),GET /projects/{id}/export(usesproject.name— the existing sanitiser atprojects.py:1648usesc.isalnum()which passes non-ASCII Unicode through, so the crash propagated), and_stream_pdfinlabels.py(latent — current callers pass ASCII-only template names, but the same shape would crash if a future caller passed user input). Fix: new helperbackend/app/utils/http.py::build_content_disposition(filename, disposition="attachment")returns an RFC 6266-compliant header with both an ASCII-stripped legacyfilename="..."fallback and an RFC 5987filename*=UTF-8''<percent-encoded>parameter — every modern browser (Chrome / Firefox / Safari / Edge) prefers the*=form when present, so the original filename round-trips intact through Save-As; the ASCII fallback covers IE10-era clients. Helper wired in at all four call sites in one PR (per project rule: no deferred follow-ups). Tests: 20 unit tests intest_http_utils.pypinning ASCII-fallback rules across plain ASCII / Chinese / Japanese / Arabic / French diacritics /.gcode.3mfdouble-extension / quote-injection / backslash-injection / empty-string and___.zipedge cases, asserting the helper's output round-trips through latin-1 (the crash condition) for every test input. 6 new integration tests intest_printers_api.py::TestPrintersAPI::test_download_printer_file_non_ascii_filenameparametrized over the same character classes (the original龙泡泡石墩子_p2s_ok.gcode.3mfcase from #1245 is included) — each asserts the route returns 200 with an unmangled body, the ASCII fallback in the header matches expectations, andunquote(filename*=)round-trips back to the original Unicode filename. Thanks to @1000Delta for the diagnosis and the proof-of-concept patch onprinters.py— the broader audit (three sibling endpoints, helper extraction, latin-1 round-trip assertions) was done on top of that.
[0.2.4b3] - 2026-05-08
Added
- Slicer Bundle (.bbscfg) import — pick presets from a stored bundle instead of resolving cloud/local/standard PresetRefs every slice — Closes the long tail of preset-resolution corner cases (cloud presets behind login, "from User" sentinel handling, the
#-prefix clone trick, danglinginheritson renamed parents, etc.) by letting users upload a BambuStudio "Printer Preset Bundle" (.bbscfg) once per printer and pick from it for every subsequent slice. Service layer (backend/app/services/slicer_api.py):BundleSummary/BundleNotFoundErrortypes,import_bundle/list_bundles/get_bundle/delete_bundlemethods,slice_with_bundlewhich posts/slicewith bundle id + per-category preset names instead of the JSON triplet. Routes (/api/v1/slicer/bundles, all gated onPermission.LIBRARY_UPLOAD):POST/GET/GET :id/DELETE :id. All routes proxy via_resolve_slicer_api_urlso they follow the user'spreferred_slicersetting (bambu_studio vs orcaslicer). Status-code mapping treats sidecar 4xx as 400,BundleNotFoundErroras 404, sidecar unreachable as 503, and sidecar 5xx as 502. Preview-slice (backend/app/services/slice_preview.py::get_preview_filaments) picks up optionalbundle_id+printer_name+process_name+filament_namesparams and routes throughslice_with_bundlewhen set; the cache key picks up a bundle-context fingerprint so different bundle picks on the same file occupy distinct entries — gram numbers in the preview now match what the real print will produce instead of being derived from the file's embedded process settings (which can drift from the triplet the actual slice would use). Thelibrary.pyandarchives.py/filament-requirementsroutes forward the new params. Dispatch (SliceRequest.bundle: SliceBundleSpec): when set,_run_slicer_with_fallbackskipsresolve_preset_refand callsslice_with_bundle; the validator skips the preset-required check so bundle-only requests validate. 3MF + bundle CLI 5xx still falls back to the embedded-settings slice path (used_embedded_settings=Truesurfaces in the response), and sidecar 404 (unknown bundle / preset name) maps to 400. Frontend SliceModal Bundle tier: new "Slicer bundle" picker at the top of the modal, rendered only when at least one bundle is imported (GET /slicer/bundlesnon-empty). Selecting a bundle replaces cloud / local / standard preset dropdowns with bundle-scoped pickers (process + per-slot filament names from the bundle) — printer is implicit (each.bbscfghas exactly one). "None" leaves the modal on the original preset-triplet path. Submit routes throughSliceRequest.bundleso the backend skips PresetRef resolution and asks the sidecar to materialise the JSON triplet from the stored bundle by name. Frontend types:SliceBundleSpec+bundle?: SliceBundleSpeconSliceRequest;getLibraryFileFilamentRequirements/getArchiveFilamentRequirementsaccept an optional 4th-arg bundle context object. The orca-slicer-api fork's bundle endpoints (shipped onbambuddy/bundle-import) are the server side of this — see the slicer-api sidecar docker-compose for the matching versions.
Fixed
- SpoolBuddy with Spoolman enabled: NFC tag scan looked up local DB first, ignored Spoolman setting; "Assign to AMS" did nothing on freshly-linked spools; AMS slot picker hid the assigned spool's info and unassign action; LinkSpoolModal showed "Unknown color" for every Spoolman spool; tag-write didn't enforce uniqueness so the wrong spool resolved on scan; kiosk display held stale assigned-state forever — Several intertwined bugs surfaced during
feature/spoolman-inventory-uitesting; fixing them as one batch because they all live on the SpoolBuddy + Spoolman path. (1)/spoolbuddy/nfc/tag-scannedalways tried local DB first and only consulted Spoolman as a fallback on local-DB miss, so a stale local copy of a tag silently won over the authoritative Spoolman row, and deleting the local copy was the only way to surface the Spoolman match. Now the route gates on_get_spoolman_client_or_none(db)(which already encodes thespoolman_enabledsetting + SSRF guard) and routes to whichever inventory backend Bambuddy is configured for — Spoolman exclusive when enabled, local exclusive otherwise. (2) Dashboard "Assign to AMS" button was a no-op when the freshly-matched spool wasn't yet in the cachedgetSpoolmanInventorySpoolsquery result (newly created or unarchived in Spoolman after the dashboard loaded). The card rendered via its owndisplayedSpool ?? sbState.matchedSpoolfallback, but the modal's stricterdisplayedSpool && !justLinkedSpool && displayedTagIdguard silently failed to mount. NeweffectiveModalSpoolsynthesises anInventorySpool-shaped object from the WebSocket-deliveredMatchedSpool(a 9-field subset;slicer_filament*are absent but the modal only usesidto route the assign API call and the mismatch check yields'none'for profile in either case). (3) AMS-page slot picker hid the assigned spool entirely — when a slot had aSpoolmanSlotAssignment(assigned via the dashboard's Assign-to-AMS flow) but no tag-linked spool, the picker explicitly returnednullfor the assign/unassign branch and only the "Configure" button remained visible. Now the picker resolves the assignment fromspoolmanSlotAssignmentsAll + spoolmanInventorySpoolsCache, renders a "Assigned spool: brand · material - color" info card, and exposes an Unassign button wired to a newunassignSpoolmanSlotMutation(callsDELETE /spoolman/inventory/slot-assignments/<id>, mirroring the local-mode flow). (4)LinkSpoolModalshowed "Unknown color" for every Spoolman spool because Spoolman doesn't standardisecolor_name— most installs only populatecolor_hexand the filament'sname(which often carries the colour like "PLA Basic Red")._map_spoolman_spoolnow falls back to the filament's subtype (filament name minus material prefix — typically "Basic Red") whencolor_nameis empty, so spools are visually distinguishable in the picker without changes to the frontend. (5) Writing a tag for spool B didn't clear the same tag binding from spool A, so a single physical NFC UID could map to two Spoolman spools at once andfind_spool_by_tagreturned whichever came first in the cached list (typically the older one) — exactly the symptom maziggy hit during testing where re-writing a tag still surfaced the previously-assigned spool.nfc_write_resultnow searches Spoolman for any other spool currently bound to the target UID and clears itsextra.tag(best-effort: cleanup failure logs a warning but doesn't block the write itself, since the device already wrote the chip). (6) The kiosk display held stalespoolmanSlotAssignmentscache because the SpoolBuddy display is a long-running browser window with no focus/remount triggers, so astaleTimealone never caused a refetch. State changed elsewhere (Bambuddy main UI, direct Spoolman edit) was invisible to the kiosk andisSpoolAssignedreported assigned-forever — the Assign button stayed disabled, the Unassign button stayed enabled, after the spools were already unassigned. AddsrefetchInterval: 3_000(cheap query, bounded latency below operator-noticeable) so the kiosk picks up external changes within seconds. (7) Kiosk QuickMenu System buttons (Restart Daemon / Restart Browser / Reboot / Shutdown) all 403'd silently — the/spoolbuddy/devices/<id>/system/commandroute was gated onPermission.SETTINGS_UPDATE(T-Gap 2 from a prior security audit), but every other kiosk-scoped device route (calibration/tare,display,cancel-write,system/command-result) usesINVENTORY_UPDATE. The kiosk's operator session hasINVENTORY_UPDATEbut notSETTINGS_UPDATE, so every System button silently failed via the modal's catch-block (no toast). Aligned the permission with the rest of the kiosk-scoped routes so operators can recover the kiosk from the kiosk itself. Risk is bounded — only the 4 named commands are accepted (no RCE), reboot/shutdown require physical-access recovery, the same operator already controls printers + weighs spools on the same device. The/updateroute keepsSETTINGS_UPDATEbecause that one can replace the daemon binary, which is a different threat surface. Test contracttest_system_command_requires_settings_updateis renamed totest_system_command_accepts_inventory_updateand asserts the inventory-only key now reaches the device-state check (409 offline) instead of 403, so a future re-tightening of the gate surfaces immediately. Tests: newTestMapSpoolmanSpool::test_color_name_uses_explicit_field_when_present/_falls_back_to_subtype_when_field_missing/_none_when_both_fields_empty(3 unit tests pinning the colour-name fallback chain), newTestNfcEndpoints::test_tag_scanned_spoolman_mode_skips_local_lookup(verifiesget_spool_by_tagis never called when Spoolman is enabled, even when the lookup would have returned a spool), and newtest_write_result_clears_duplicate_tag_binding(assertsmerge_spool_extrais called twice — once to clear the old holder'sextra.tag, once to bind the new owner — in that order with the right spool ids). Existing 76 helper tests + 7 NFC-endpoint tests still pass. - Spool assignment to a reset AMS slot left the slot unconfigured both in Bambuddy and on the printer — Reproduced during
feature/spoolman-inventory-uitesting (extends the #1228 family). After clicking "Reset slot" on an AMS slot that had filament physically loaded, picking an inventory spool from the printer card and clicking Assign showed a success toast — but the slot kept reporting as unconfigured, noams_filament_settingMQTT command ever fired, and the spool's brand/color never appeared on either the Bambuddy printer card or BambuStudio. Cause:assign_spoolinbackend/app/api/routes/inventory.pydecided the slot was empty usingslot_is_empty = not (fingerprint_type and fingerprint_type.strip())wherefingerprint_typecame fromtray.tray_type. The "Reset slot" command clearstray_type/tray_color/tray_info_idxto empty strings on the printer side but leaves the filament physically loaded. The emptytray_typethen misled the heuristic into the pending-config (SpoolBuddy weigh-then-assign) branch, which intentionally skips the MQTT publish because Bambu firmware dropsams_filament_settingon truly unloaded slots. The deferred replay inon_ams_changeonly fires on an empty→loaded transition — but the slot was already loaded, so no transition ever came and the assignment sat in pending state forever. Fix: capturetray.statealongside the fingerprint fields when looking up the AMS tray (Bambu firmware reportsstate == 11for loaded,9for empty,10for spool present but filament not in feeder; documented atbambu_mqtt.py:1631-1633). Whenstateis reported,slot_is_empty = (state != 11). Whenstateis not reported (older firmware), fall back to the existingtray_typeheuristic so legacy installs continue to behave the same. Same logic applied to the external-slot path (ams_id == 255/vt_tray). Tests: 5 new inTestAssignSpoolEmptyDetection— post-reset (state=11, tray_type=""→ MQTT must fire,pending_config=False), genuinely empty (state=9→ MQTT skipped,pending_config=True), legacy fallback both directions (nostatefield → tray_type heuristic), and the external-slot post-reset variant. - Slicer "Send to printer" silently rejected the cached push_status with "storage needs to be inserted" on P1S/A1-class targets (#1228, reported by @rtadams89, also hit by @smandon) — Slicer "Send" worked on 0.2.3.2 with a queue-mode VP and started failing on 0.2.4b3, regardless of subnet topology, with BambuStudio showing the generic "storage needs to be inserted before send to printer" error. Reproducible across Docker bridge, macvlan, and LAN-attached host networking. Network reachability ruled out (slicer reaches MQTT/FTPS, FTP passive ports 50000-50100 reachable end-to-end, pfSense rules clean). The smoking gun was in @rtadams89's debug-level support archive: slicer establishes MQTT TLS to the VP, gets
pushall+get_versionresponses, then never opens an FTP connection — the slicer reads the cached push, fails its pre-flight, and aborts before attempting any data transfer. Cause: the 0.2.3.2 synthetic stub baked three SD/storage indicators that BambuStudio's "Send" pre-flight reads —home_flagwith bit 8 (HAS_SDCARD_NORMAL,0x100),sdcard: True, and astorage: {free, total}block. The 0.2.4b3 cached-as-base slicer-mirror (commit7dea33d0) passes the live target's push_status through with only an IP rewrite; if the real firmware doesn't report those fields (P1S/A1 with no SD card inserted, older field shapes, P1S firmware01.10.00.00confirmed in @rtadams89's logs), the slicer sees "no storage" and refuses to send. H2D and X1C in maziggy's local cross-subnet repro worked because those firmwares do report the indicators; P1S/A1-class doesn't always. Fix: inmqtt_server.py:_send_status_reportcached-as-base path, after copying the cache, OR0x100ontohome_flag(preserves any other bits the printer set), forcesdcard=True, andsetdefaultastorage: {free: 1_000_000_000, total: 32_000_000_000}block (only fills in if the real printer didn't report one — real values pass through unchanged when present). For VP usage the slicer uploads via FTPS to Bambuddy's filesystem under/app/data/virtual_printer/uploads/<vpid>/; the printer's actual SD card is irrelevant on that path, so forcing "storage available" is correct for the queue/immediate/review modes the cached-as-base path covers. Restores 0.2.3.2's working behaviour for these specific fields without losing the live AMS / k-profile / camera mirror that cached-as-base provides. Tests: newtest_storage_indicators_overlaid_for_send_preflight(verifies SD bit OR'd onto a partialhome_flag,sdcard=Trueforced even when real says False,storageinjected when cache lacks it, free/total are non-zero) andtest_storage_indicators_preserve_real_storage_when_present(realhome_flag=0x100stays0x100, realstorage={free, total}passes through unchanged so the overlay never overrides what the printer actually reported) intest_vp_mqtt_bridge.py::TestStatusReportCachedAsBase. Existing 25 tests in that suite still pass. - MFA at-rest encryption is now default-on via auto-bootstrap (#1219) — Default Docker installs ran with
MFA_ENCRYPTION_KEYunset, which silently fell back to plaintext storage for OIDCclient_secretand TOTP secret rows. The single startuplogger.warningwas the only signal, and.env.example/docker-compose.yml/ Settings UI never mentioned the variable, so any operator who wired up SSO or asked users to enroll in 2FA had to read the warning in the logs to know their secrets were unprotected at rest. Auto-bootstrap:backend/app/core/encryption.pynow resolves the encryption key with the same precedence pattern as_get_jwt_secret—MFA_ENCRYPTION_KEYenv var →DATA_DIR/.mfa_encryption_keyfile → auto-generated Fernet key written with mode0o600. The new helperbackend/app/core/paths.py:resolve_data_dir()is shared withauth.py(DRY) and reads the env fresh on every call so test fixtures can overrideDATA_DIRper-test. Invalid env-var values (anything that doesn't decode to exactly 32 bytes via URL-safe base64) are rejected with alogger.errorand the loader falls through to the file/auto-generate branches instead of crashing the encrypt/decrypt path withValueError. Re-encryption migration:_migrate_encrypt_legacy_secrets()runs once on every startup afterrun_migrations(conn)finishes — it opens its ownasync_session()(separate from the schema-DDL connection, to avoid SQLite WAL lock contention) and converts anyoidc_providers.client_secret/user_totp.secretrow whose value doesn't already start withfernet:to the encrypted form via the existing property setters. The migration is idempotent (prefix check) and is a no-op when no key is loaded, so it can run safely on installs that never opt in. Status endpoint + UI: newGET /api/v1/auth/encryption-status(admin-only, gated onPermission.SETTINGS_READ) returnskey_configured,key_source ∈ {env, file, generated, none}, plus per-tablelegacy_plaintext_rowsandencrypted_rowscounts and a deriveddecryption_brokenflag (true iff encrypted rows exist but no key is loadable — the Phase-2 "operator deleted the key after rows were encrypted" recovery scenario). The newfrontend/src/components/SecurityStatusCard.tsxlives in a new "Security" sub-tab under Settings → Authentication and renders four severity levels: green when everything is encrypted and a key is loaded, yellow when legacy plaintext rows still need re-encryption, orange when the key was auto-generated (with a backup hint pointing atDATA_DIR/.mfa_encryption_key), and red whendecryption_brokenis true. Backup integration:routes/settings.py:create_backup_zipnow includes.mfa_encryption_keyas a ZIP top-level entry (alongsidebambuddy.db) so a self-contained backup can be restored to a fresh host without losing access to encrypted secrets. The matchingroutes/settings.py:restore_backupextracts the file back intoDATA_DIRwithchmod(0o600)and validates the basename exactly (/,..,\\rejected) so a manipulated ZIP cannot path-traverse outsideDATA_DIR. If the file is absent from the ZIP (legacy backup) the restore proceeds without error — the next boot will auto-bootstrap a fresh key, and any plaintext rows that come back from the backup remain readable via the existing legacy-plaintext fallback inmfa_decrypt. Test isolation: new autousemfa_encryption_isolationfixture inconftest.pyper-test pointsDATA_DIRat atmp_path, clearsMFA_ENCRYPTION_KEYfrom env, and resets the_fernet_instance/_warn_shown/_key_sourcemodule globals — so the auto-bootstrap can never write a real key file into the repo and pytest-xdist workers don't share encryption state. i18n: newsettings.encryption.*namespace andsettings.tabs.securitylabel across all 8 locales (en + de fully translated; fr/it/ja/pt-BR/zh-CN/zh-TW seeded with English copy pending native translation, matching the project's existing flow for newly-added keys). Docs:.env.exampledocuments the new variable + the backup self-containment behaviour;docker-compose.ymlcarries an auto-commented entry;.gitignoreadds.mfa_encryption_keyalongside the existing.jwt_secretproject-root guard. Tests: 9 new unit tests inTestEncryption(env/file/generated key sources, invalid-env fall-through, OSError →none, mode0o600check), 6 new inTestEncryptLegacyMigration(plaintext → encrypted for OIDC + TOTP, idempotent re-run, mixed state, no-op without key, log assertion), 8 new inTestEncryptionStatusEndpoint(eachkey_source, count assertions,decryption_brokenrecovery scenario,Permission.SETTINGS_READgate), 2 new inTestEncryptionRoundtrip(raw column reads return ciphertext, property reads return plaintext for both OIDC and TOTP), 6 new inTestBackupKeyFiles(ZIP includes / skips key files, restore chmod0o600, missing-file tolerance, path-traversal rejection), and 6 new frontend tests inSecurityStatusCard.test.tsx(each severity level + the disabled state). - Camera preview popup opened to a blank page; deep-route refresh and direct URL load broken (#1221, reported by @enjoylifenow / @Haeckan / @elit3ge / @jc21) — Clicking "open camera in new window" from the printer card opened a popup that rendered as an empty white page across P1S / P2S / X1 series, every install method (Docker / git clone), every browser (Chrome / Firefox / Brave / Safari), starting with the daily build of 2026-05-05. Cause: PR #1195 (
d6a31393, "fix(frontend): emit relative asset paths so SPA loads under any subpath") setbase: ''invite.config.tsto support path-prefixed reverse proxies (HA Ingress, nginx subpath, Cloudflare Tunnel path routing). With that, the builtindex.htmlreferences its bundle and stylesheet via relative URLs (./assets/index-XXX.js,./sw-register.js). When the popup opened at/camera/<id>, the browser resolved./assets/index-XXX.jsagainst the current document URL — which doesn't end in a slash, so the URL parser treated<id>as a file and/camera/as the directory, giving/camera/assets/index-XXX.js. The backend's SPA catch-all returnedindex.html(text/html) for that request, and modern browsers refuse to execute HTML as a JS module underX-Content-Type-Options: nosniff, so the popup loaded the document but never the bundle. Same break hit any deep route on initial load — direct URL paste / refresh on/camera/:printerId,/projects/:id,/groups/:id/edit,/files/trash,/external/:id, and the SpoolBuddy kiosk's/spoolbuddy/amsif loaded directly — manifesting as a quiet "blank page on refresh" that users worked around by navigating from the home page. The console error gives it away:Loading module … was blocked because of a disallowed MIME type ("text/html"). Fix: revert PR #1195'svite.config.tsandsw-register.jschanges —base: ''is removed (Vite default'/'restored), andnavigator.serviceWorker.register('sw.js')reverts toregister('/sw.js'). The builtindex.htmlnow emits absolute asset URLs (/assets/...,/manifest.json,/sw-register.js) which resolve against host root regardless of document URL, so deep routes load their assets correctly on initial navigation. PR #1195's class of bug — path-prefixed reverse proxy users serving Bambuddy at a subpath — was already explicitly closed as wontfix in that thread because supporting it requires subpath-aware bootstrapping (API_BASE, React Router basename, PWA manifest scope, service-worker scope, push-subscription scope) for every user forever. The supported workaround for that audience is documented: NPM (Nginx Proxy Manager) addon + Cloudflare Tunnel at a real domain with HTTPS, then HA Webpage panel embedding viaTRUSTED_FRAME_ORIGINS— that path doesn't depend onbase: ''at all. The trade-off here is intentional: revert reaches every user impacted by deep-route initial-load bugs (much larger population than path-prefixed proxy users), in exchange for an already-wontfixed subpath proxy regression that has a working alternative. (#1237, reported by @basziee) — In the Configure AMS Slot modal, profile names likeSUNLU PETG GLOW IN THE DARK GEN2 @Bambu Lab H2C 0.4 nozzlewere visually truncated mid-name, hiding the@<printer> <nozzle>suffix. With several near-identical entries differing only in nozzle size, users had to open browser dev tools to tell them apart. Fix: the preset row now expands inline on hover —truncatestays as the default (so the list keeps its compact one-line shape) butgroup-hover:whitespace-normal group-hover:break-allflips it to a wrapped multi-line view the moment the cursor enters the row, so the nozzle suffix is readable instantly without waiting on the browser's title-tooltip delay. The parent button getsgroupto drive the hover. The nativetitle={preset.name}is also added as a belt-and-braces fallback for assistive tech and touch devices where:hoverdoesn't fire. Same pattern in both the desktop and mobile layouts ofConfigureAmsSlotModal.tsx. No new dependencies. Test: newConfigureAmsSlotModal.test.tsxregression assertion that the rendered preset span carriestitle=<full name>plus thetruncateandgroup-hover:whitespace-normalclasses, and the parent button hasgroup— so a future refactor that drops any of those fails CI. - Filament usage double-counted when AMS auto-falls-back to a same-material spool (#957) — When one spool ran out mid-print and the AMS transparently switched to a sibling slot loaded with the same material, the usage tracker credited the originally-mapped spool with the full 3MF estimate AND added the fallback spool's remain%-delta on top — so a 78 g print could show as 78 g + 60 g = 138 g consumed across the two spools, leaving the empty spool's recorded weight beyond its label weight (the symptom the original report flagged on a 1209 g spool reading "1188.30 g used" while the new spool only got a 30 g credit). Two interacting bugs: (1) the tray-change recorder in
bambu_mqtt.pygated onstate in ("RUNNING", "PAUSE")literal strings, and P2S firmware briefly transitions out of RUNNING during the AMS swap, so the switch was never appended totray_change_log; (2) the usage-tracker splitting branch inusage_tracker.pywas gated onnot slot_to_tray, so even when the tray-change log was populated the splitting code only ran for prints where the slicer's mapping had not been captured — i.e. never on the actual fallback case. Fix: thebambu_mqtt.pygate now keys on the print-lifecycle flags (_was_running and not _completion_triggered) so any tray change between print start and completion is captured regardless of the momentarygcode_statestring. Theusage_tracker.pygate is split sotray_change_logevidence with > 1 entries always takes over fromslot_to_tray, treating the per-segment per-layer gcode usage as the source of truth when the printer actually fed from multiple trays. Path 2 (AMS remain%-delta fallback) then naturally skips both trays because they're already inhandled_traysafter splitting, eliminating the double-credit. Tests: newtest_tray_change_recorded_during_intermediate_stateandtest_tray_change_not_recorded_after_completionintest_bambu_mqtt.pyexercising the new gate; newtest_tray_switch_overrides_print_cmd_mappingintest_usage_tracker.pypinning that withams_mapping=[0]set andtray_change_log=[(0,0),(1,30)]the splitter produces two segments summing to the 3MF estimate (no double-count) and adds both(0,0)and(0,1)tohandled_trays. - 3D Preview returned
{"detail":"Not Found"}in Docker installs (#1218) — The embedded GCode viewer's static assets (gcode_viewer/) were not copied into the production Docker image, so clicking "3D Preview" on any archive loaded an iframe at/gcode-viewer/?archive=<id>that returned a bare FastAPI 404 — Firefox / Chrome rendered the JSON response inside the iframe area while the outer Bambuddy layout looked normal, masking the failure unless the user actually inspected the iframe. The Vite production build doesn't stagegcode_viewer/intostatic/either (the dev server serves it via aconfigureServermiddleware that's dev-only), and the only integration test for the route accepted404as a valid outcome ("assert response.status_code in (200, 404)") so CI never caught the missing files. Affected every Docker build since the embedded viewer landed in 0.2.4b1 (commit3adce435, 2026-04-22). Fix:Dockerfilenow copies thegcode_viewer/directory alongside the React build output. Defence in depth:backend/app/main.pylogs an ERROR at startup when_gcode_viewer_dir / "index.html"is missing so future packaging gaps surface indocker logsand the support bundle instead of as silent runtime 404s. Test guard:backend/tests/integration/test_gcode_viewer.pyaddstest_gcode_viewer_index_served_when_assets_presentwhich skips when the directory is intentionally absent (unit-test environments) but asserts200 OK+ a non-empty HTML body when the assets do exist on disk — so a future brokenCOPYfails CI loudly rather than continuing to ship a broken image. - Slice button no longer enabled before the preview slice resolves — Until the preview slice (or embedded-metadata read for already-sliced 3MFs) returned the per-plate filament list, the SliceModal rendered a synthetic single-slot fallback so the auto-pick had something to bind against. That made the Slice button enabled the moment the modal opened, even before the slicer had told us which AMS slots the plate actually consumes — clicking would dispatch against opaque defaults and the real-life print would either pick the wrong filament or fail with a slot-mismatch error after the fact. Adds
filamentReqsQuery.isSuccessto theisReadychain so the button stays disabled while the preview slice is in flight (or before the backend's/filament-requirementscall settles for sliced files) and flips to enabled the moment the real slot list lands and auto-pick fills it. - New AMS RFID rolls auto-named to the wrong colour when the hex is shared across material variants (#1227) — Inserting an Ivory White (PLA Matte) roll always created a spool named "Jade White" because the colour-catalog lookup in
create_spool_from_trayfiltered by manufacturer + hex only, with noORDER BY. Three Bambu Lab catalog rows share#FFFFFF— Jade White (PLA Basic), Ivory White (PLA Matte), White (PLA Silk) — and SQLite returned them in rowid order, so the first-inserted entry (Jade White) won every time regardless of the actual material the AMS reported. Same class of bug bites any other shared-hex pair across PLA Basic / Matte / Silk; the whites were just the most visible. Fix:spool_tag_matcher.py::create_spool_from_traynow filters the catalog bytray_sub_brandstoo — the printer-reported material variant ("PLA Matte" / "PLA Basic" / "PLA Silk") matches the catalog'smaterialcolumn directly. The query also gets an explicitORDER BY idso the fallback path (whentray_sub_brandsis empty — third-party spools / OpenTag tags) is deterministic across SQLite + PostgreSQL instead of DB-implementation-defined. The catalog lookup uses the rawtray_sub_brandsvalue (before the gradient/dual/tri-color subtype upgrade at lines 73-87) because the catalog stores"PLA Basic"for gradient rolls too — the upgraded subtype lives on the spool, not the catalog row. Note for affected users: spools already in the database under the wrong colour name (e.g. four Ivory White rolls labelled "Jade White") don't auto-correct on next AMS read — the matcher only fires when creating a new spool from RFID. Existing rows need a manual rename in Inventory after upgrading. Tests: 4 new intest_spool_tag_matcher.py—test_ivory_white_pla_matte_resolves_to_ivory_not_jade(the #1227 regression pin),test_pla_silk_white_resolves_to_white_not_jade(the third collision),test_jade_white_pla_basic_still_resolves_correctly(happy-path guard with all three #FFFFFF entries seeded), andtest_unknown_material_falls_back_to_hex_only_lookup(third-party / emptytray_sub_brandspath stays deterministic via ORDER BY). - Backups to Gitea / Forgejo failed with "Failed to create tree" on empty repos and "list indices must be integers or slices, not str" on populated repos (#1224, #1225) — Two interacting bugs in the Gitea/Forgejo backend, both inherited from
GitHubBackendbecause PR #1160's class docstring assumed Gitea's Git Data API was fully GitHub-compatible. (1) List-shaped ref response:GET /api/v1/repos/{owner}/{repo}/git/refs/heads/{branch}returns a list of matching refs on Gitea/Forgejo even when only one matches ([{"ref": ..., "object": {"sha": ...}}]), whereas GitHub returns a single object. The inheritedpush_filesand_create_branch_and_pushdidref_response.json()["object"]["sha"]and crashed withlist indices must be integers or slices, not str— surfacing as the failure at the top of any push against a populated Gitea repo (#1225's symptom, and #1224's symptom once the user committed any file before the first backup). (2) Empty-repo writes refused: GitHub's Git Data API acceptsPOST /git/blobsagainst a brand-new empty repo and creates the initial commit + branch implicitly. Gitea refuses every blob/tree/commit POST with 404 until the underlying git repo has at least one commit — so the inherited_create_initial_commit(which posts blobs → tree → commit → ref in that order) silently failed: every blob POST returned 404,tree_itemsended up empty, and the next tree POST also returned 404 ("Failed to create tree" — #1224's symptom on a freshly-created empty Gitea repo). Fix:GiteaBackendnow overridespush_files,_create_branch_and_push, and_create_initial_commitdirectly instead of inheriting them. The Git Data API path uses a_ref_sha()helper that accepts both list and dict shapes; the empty-repo bootstrap route uses Gitea's Contents API (POST /api/v1/repos/{owner}/{repo}/contentswith afilesarray,branch=<target>,new_branch=<target>) which seeds the initial commit + branch in a single transaction — Contents API is documented to work on empty repos because it goes through Gitea's higher-level repo-init path.GitHubBackendis untouched — the GitHub backup path is proven working, the fix is fully isolated to the Gitea side.ForgejoBackend(GiteaBackend)inherits both fixes automatically; tests pin that. Tests: 10 new tests intest_git_providers.py—TestGiteaBackendListShapeRefResponse(4 tests:_ref_shaaccepts list/dict/empty-list, plus fullpush_fileshappy paths against list-shaped branch ref and list-shaped default-branch ref),TestGiteaBackendEmptyRepoInitialCommit(4 tests: empty repo routes through Contents API exclusively with no blob/tree/commit/ref Git Data API calls, payload shape verified field-by-field against Gitea's documented schema, error truncation works, empty file dict returnsskippedwithout firing a useless API call), andTestForgejoInheritsGiteaFixes(2 tests: list-shape and empty-repo paths both work via inheritance). Existing 6TestGiteaBackendPushFilestests still pass since_ref_shaaccepts dict-shaped responses too. Total: 78 tests pass across the backup unit + integration suites; ruff clean. Follow-up fix (still under #1224): subsequent backups against Gitea 1.24+ then failed with the opaque "Backup failed: 'tree'" because Gitea'sGET /repos/{owner}/{repo}/git/commits/{sha}returns the wrappedCommitschema (tree atcommit.tree.sha), whereas GitHub's same-named Git Database endpoint returns the unwrappedGitCommitschema (tree at top level). The barecommit_response.json()["tree"]["sha"]lookup atgitea.py:109raisedKeyError: 'tree'and the broadexceptsurfaced it as the opaque message. Fix:_commit_tree_sha()helper that tries the flat shape first (GitHub-compatible / older Gitea) and falls back to the wrapped shape (Gitea 1.24+, Forgejo) — keeps the existing-files diff working on both shapes so subsequent backups don't re-upload every blob. Tests: newTestGiteaBackendWrappedCommitResponse(4 tests: helper accepts flat / wrapped / missing shapes, fullpush_filessucceeds against a wrapped commit response, failure path surfaces a clear error message instead ofKeyErrorwhen the tree SHA can't be extracted). - Docker data-volume ownership normalised at startup via gosu entrypoint (#1211) — Two long-standing failure modes have been biting Docker users repeatedly: (1) Docker named volumes are created by the daemon as
root:root, and the previouschmod 777 /app/dataDockerfile workaround only covered the named-volume root — so subdirs Bambuddy creates at runtime (virtual_printer/uploads,virtual_printer/certs, etc.) inherited wrong ownership when the container ran as1000:1000. (2) The shippeddocker-compose.ymlships./virtual_printer:/app/data/virtual_printeruncommented, and dockerd creates a missing bind-mount source on the host as root before the container starts — leaving the host directory unwritable by uid 1000 inside the container even though the named volume above it had the chmod-777 workaround. Symptom either way:[Errno 13] Permission denied: '/app/data/virtual_printer/uploads', no virtual printer ever starts, "VP doesn't work" support reports follow. Replaces the chmod-777 hack with a proper entrypoint:deploy/docker-entrypoint.shruns as root, chowns/app/dataand/app/logs(and/app/data/virtual_printerwhen bind-mounted) toPUID:PGID, then drops to that uid viagosubeforeexec'ing the app. The chown is gated behind a top-level ownership check so subsequent restarts skip the recursive traversal — no multi-second startup penalty on multi-GB archive directories. A sentinel.bambuddyfile in each data path prevents Docker from re-syncing image directory metadata on every mount (otherwise empty volumes have their ownership reverted from the image on each restart, defeating the idempotency). When the container is started with an explicituser:directive or--userflag the entrypoint detects it isn't root and falls through to directexec— preserving compatibility for users who pin a specific uid. Compose template changes: removesuser: "${PUID:-1000}:${PGID:-1000}"(the entrypoint owns privilege drop now), addsPUID/PGIDenv vars with the same defaults, and comments out the./virtual_printer:/app/data/virtual_printerbind mount by default with explicit "only needed if you also run a native install of Bambuddy on the same host and want both to share the VP CA cert" guidance. The entrypoint chowns the host-side dir through the bind mount the first time it sees wrong ownership, so existing uncommented installs continue to work and #1211 specifically gets fixed. - Label picker modal clipped the 4th template option and Cancel button on short viewports (#1230, reported by @elit3ge) — Clicking "Print labels" from Inventory opened the picker with only 3 of the 4 templates visible (Avery 5160 was half-cut at the bottom) and no Cancel button reachable, with no way to scroll to them. Surfaced reliably on Windows 11 + Brave at 1080p with browser chrome / DPI scaling shrinking the effective viewport, but the layout bug hits anywhere the modal's
max-h-[90vh]lands below ~770 px. Cause:LabelTemplatePickerModal.tsxuses a flex column withoverflow-hiddenon the outer modal, the spool list as theflex-1shrinkable child, and the templates section + footer as fixed siblings below it. The spool list hadmin-h-[160px], which combined with the defaultmin-height: autofor flex items meant the spool list couldn't yield space when the modal was tight — the templates and footer overflowed the modal's bottom edge and got clipped. First fix (insufficient):min-h-[160px]→min-h-0on the spool list scroller, which both removes the fixed floor and overrides the implicitmin-height: auto. That made the spool list shrink, but on the user's 838 px viewport with browser chrome eating into 90 vh the four stacked templates (~310 px) plus footer still didn't fit, leaving Avery 5160 half-cut and the Cancel button below the modal's clipped bottom edge —elit3geconfirmed the dev build was still broken after that fix. Second fix: the templates section now renders as a responsive grid (grid-cols-1 sm:grid-cols-2 gap-2) so the four buttons pack into a 2×2 grid above thesmbreakpoint, trimming ~150 px of vertical inside the modal. Each cell tightens its label/hint totext-sm+truncate(with the full strings reachable via the newtitle=label — hinton the button so the truncation never hides information), padding shrinks top-2.5, and the footer'spy-3is dropped topy-2for a few extra pixels. The earliermin-h-0on the spool list is kept as a belt-and-braces shrink for any viewport tighter still. Pre-existing ondevsince 0.2.4b2 (commit864e5c99, the original PR #809 that introduced the modal); not a regression from the spoolman-inventory rebase. Test: the regression test inLabelTemplatePickerModal.test.tsxis upgraded to pin the new structural shape — the templates container hasgrid+grid-cols-1+sm:grid-cols-2and exactly 4 child buttons, plus the existing assertions that all 4 template names + the Cancel button render and the spool list scroller still hasmin-h-0with no fixedmin-h-[…]literal. So a future refactor that drops the grid and reintroduces stacked rows fails CI.
Security
-
urllib3 floor raised to 2.7.0 to clear CVE-2026-44431 and CVE-2026-44432 —
urllib3is a transitive dependency (none of Bambuddy's top-level deps require>=2.7.0yet), so the resolver was silently keeping the vulnerable2.6.xline.requirements.txtnow carries an expliciturllib3>=2.7.0pin under the HTTP-client section with an inline comment explaining the indirect-dep rationale, so a futurepip install -r requirements.txtrebuild picks up the upstream-fixed release. No Bambuddy code change — the affected code paths live inside urllib3 itself. -
python-multipart bumped to 0.0.27 to clear CVE-2026-42561 —
requirements.txtfloor raised from>=0.0.26to>=0.0.27. python-multipart is the multipart/form-data parser FastAPI uses forUploadFilebody parsing, so it sits on every Bambuddy upload path (3MF/STEP/STL upload, label-template imports, OIDC certificate upload, backup restore, etc.). The advisory is a parser-side issue against malformed multipart input; Bambuddy doesn't expose unauthenticated upload endpoints (every multipart route is gated on eitherPermission.LIBRARY_UPLOAD/SETTINGS_UPDATE/INVENTORY_UPDATE), so blast radius is bounded to authenticated callers — but the bump is mechanical and the floor was already loose, so no reason to wait.
[0.2.4b2] - 2026-05-05
Changed
- Virtual Printer Tailscale toggle no longer provisions Let's Encrypt certs — it's now informational — The original promise of the
tailscale_disabledtoggle was that flipping it on would obtain an LE cert viatailscale certso users wouldn't need to import Bambuddy's CA into the slicer. End-to-end testing exposed that this was always going to fail: BambuStudio and OrcaSlicer both refuse hostname input in the Add Printer dialog (IP-only), and — more fundamentally — their printer-MQTT trust path validates only against the bundled BBL CA store (printer.cer), not the system trust store. Confirmed against ClusterM/open-bambu-networking's clean-room reimplementation:mosquitto_tls_set(BBL_CA)+mosquitto_tls_opts_set(verify_peer=1)+mosquitto_tls_insecure_set(true)— chain validation against BBL CA only, hostname check intentionally skipped (because Bambu's printer cert CN is the device serial, not an IP/hostname). LE-issued certs don't chain to BBL CA, so the slicer rejects with the well-known "-1" before any hostname/IP logic runs. The cert-import step is unavoidable; the LE provisioning was dead code for slicer connections. What stays: the toggle, the/virtual-printers/tailscale-statusroute, the docker socket mount, and the host-level Tailscale information surfaced on the VP card (IP + MagicDNS hostname + copy button) so users know what to paste into the slicer when they pick the Tailscale interface from the bind_ip dropdown. Tailscale's role is now strictly network reach — private WireGuard tunnel to the VP from any tailnet device, no port forwarding — exactly the same trust burden as LAN. What goes:provision_cert/ensure_cert/cert_needs_renewaland the daily renewal task / restart-on-renewal plumbing on the manager (_cert_renewal_task,_cert_restart_task,_cert_renewal_loop,_restart_for_cert_renewal,_cancel_renewal_task,_cancel_restart_task); thetailscale_fqdnfield surfaced via VP status (cert side-effect); thetailscale_not_available409 guard on toggle-enable in bothroutes/virtual_printers.pyandroutes/settings.py(toggle is informational, daemon presence doesn't block flipping it);CertificateService.{ts_cert_path, ts_key_path, use_tailscale_cert}and the LE cert files on disk (virtual_printer_ts.{crt,key}left in place per-VP — harmless residue, can be deleted manually). Thetailscale_disabledDB column is kept as the persisted toggle state. Tailscale FQDN/IP on the VP card is now sourced from the existing/tailscale/statusendpoint (host-level) rather than from per-VP cert provisioning side-effect — the data is the same regardless of which VP you're looking at, since each host has one Tailscale identity. Wiki, README, and i18n copy updated across all 8 locales to drop the "no cert import needed" framing — toggle's helper text now says it surfaces the Tailscale address and that CA import is unchanged. Tests:test_tailscale.pyreduced to the survivingget_statuscases (binary missing, command fails, success, empty DNSName, malformed JSON);test_virtual_printer.py::test_sync_from_db_restarts_on_tailscale_disabled_changerewritten astest_sync_from_db_does_not_restart_on_tailscale_toggle(toggle is informational —remove_instancemust NOT be called when onlytailscale_disabledchanges);test_virtual_printer_api.py::TestVirtualPrinterTailscaleGuardAPIcollapsed to a singleTestVirtualPrinterTailscaleToggleAPI::test_toggle_does_not_consult_tailscale_daemonthat asserts both directions succeed andget_statusis never called. FrontendVirtualPrinterCard.test.tsxmock now stubsgetTailscaleStatusand the FQDN-copy block drives the FQDN through that query rather than VP status.
Added
-
Spool label printing (#809) — Closes the longest-standing inventory gap: there's now a per-spool "Print label" button on every Inventory card and a "Print labels (N)" header action that prints labels for the currently filtered view. Generates a PDF in one of four fixed sizes — AMS holder (30×15 mm) for the popular Makerworld AMS Filament Label Holder, single box label (62×29 mm) for Brother PT/QL or Dymo small labels, Avery L7160 for A4 sheet stock (38.1×63.5 mm × 21 per page), and Avery 5160 for US Letter sheet stock (25.4×66.7 mm × 30 per page) — and opens it in a new browser tab so users can print or save. Each label shows a colour swatch (with multi-colour gradient stripes for spools that have
extra_colorsset), brand + material, the spool's own name, the spool ID (the field bsaunder flagged as the most-needed for "find spool 7 in my closet" identification), and a QR code that deep-links to/inventory?spool=<id>so a phone scan jumps straight back to that spool's row in Bambuddy. The box-size template additionally surfaces the storage location field. Architecture:backend/app/services/label_renderer.pyis a pure-Python renderer using ReportLab (no headless browser, no system libs) and qrcode (already a dependency); the QR target uses the configuredexternal_urlsetting if present so phone scans reach the right hostname, otherwise falls back to the request's own scheme+host. Renderer is fully decoupled from the SQLAlchemy model — input is aLabelDatadataclass list — so the same code path serves both the local DB inventory and the Spoolman-backed inventory once the dedicated UI lands. Two endpoints:POST /inventory/labels(local) andPOST /spoolman/labels(Spoolman-backed; fetches via the existing client and filters in-memory). Both gated onPermission.INVENTORY_READ, both cap requests at 500 spools per call to bound rendering time, both streamapplication/pdfdirectly. Why server-side and not browser print? Server-side gives consistent output across browsers, Avery sheet templates that align to <0.1 mm (browser print scaling drifts 2–3 mm per page), one-click "download all 30 selected as one PDF", no print-dialog header/margin fiddling, and reproducible output for support — at the cost of one new pure-Python dep and ~250 lines of layout code. Out of scope for V1: direct-to-label-printer drivers (Dymo / Brother / Zebra ZPL — each is its own multi-week project, follow-up issue per vendor if demand surfaces), user-customizable HTML/CSS templates / template DSL (the four built-ins cover the use case bsaunder articulated; templating engines are where this kind of feature usually drowns), and the "global label mixin for spools/projects/printed parts" framework Keybored02 sketched (right direction for a future feature, not for V1). On the dev branch the local-mode UI is wired; the Spoolman-mode UI defers to the in-flightfeature/spoolman-inventory-uibranch where the unified Spoolman picker lives. Tests: 15 unit tests intest_label_renderer.py(each template produces a valid PDF, empty input returns valid empty PDF, unknown template raises, multi-colour swatch survives 4+ stops, missing optional fields don't crash, malformed rgba falls back to grey, long strings are truncated not overflowed, sheet templates paginate when count exceeds one sheet, QR-bearing PDFs are noticeably larger than QR-less ones); 11 integration tests intest_labels.py(both modes produce PDFs, all four templates succeed, unknown template / empty list / unknown spool ID rejected with the right code, request order preserved into the renderer so Avery sheets match the on-screen list, Spoolman path returns 400 when disabled / 503 when unreachable / 404 when spool missing / 200 with the expected content-type when it works, request body capped at MAX_LABELS_PER_REQUEST); 7 frontend tests inLabelTemplatePickerModal.test.tsx(modal absent when closed, four templates rendered, singular vs plural subtitle, spoolmanMode false routes to local API and vice versa, neither API called when the other mode is active, error path keeps the modal open so the user can retry). All 8 locales get the newinventory.labels.*key set with English strings (other locales seeded with English copy pending native translation, matches the project's existing flow for newly-added user-facing features). -
Virtual Printer non-proxy modes now mirror the live target printer to the slicer (#1193 follow-up) — Until now, Immediate / Review / Print Queue VPs looked like a stub Bambu Lab printer to the slicer: AMS dropdowns were empty, no live state, no camera, no per-filament k-profile lookup. The user could send a sliced file and that was it. With this change, the VP fans out the target printer's live MQTT state to the slicer (AMS units, FTS / dual-extruder routing, nozzle, temps, k-profiles, AMS load / dry / calibration commands) and proxies the camera RTSPS stream on port 322 — so the slicer treats the VP as a fully-functional Bambu printer while Bambuddy's queue / archive / dispatch features stay in the loop. Architecture (cached-as-base, single source of truth): the bridge caches the latest real
push_statusandinfo.get_versionresponse from Bambuddy's existing per-printer MQTT subscription (no second session on the printer — firmware in-flight budget unaffected, see #1164). The VP's_send_status_reportreturns a near-byte-identical copy of the real push with only the upload-state-machine fields (sequence_id, command, msg, gcode_state, gcode_file, prepare_percent, subtask_name) overridden under our control, so BambuStudio's Send pre-flight sees exactly the same shape as a direct-to-printer connection. Command responses (extrusion_cali_get, AMS write acks, xcam responses) are fanned out raw — they carry sequence_ids the slicer is waiting on. Slicer-issued commands forward to the real printer exceptprint.project_file/gcode_file, which are still answered locally because the file lives on Bambuddy. Field-shape gotchas worth remembering: (1) Real Bambu printers wire-format push_status JSON withindent=4(32 254 bytes for an idle H2D push, vs 14 268 bytes compact) — BambuStudio's Send pre-flight rejects compact JSON silently, so_publish_to_reportwas switched tojson.dumps(payload, indent=4). (2)net.info[*].ip(little-endian uint32, e.g. 192.168.255.133 → 2248124608) is the FTP destination IP BambuStudio uses for "Send to Printer storage" — it overrides anything else, including the URL hosts the rest of MQTT advertises. The bridge rewrites this to the VP's bind IP on cache, otherwise the slicer FTPs straight to the real printer and bypasses Bambuddy entirely (symptom: "Failed to send" with zero inbound FTP connections on the VP — debug-by-tcpdump if anyone hits it again). (3)upgrade_state.snand any other nested-dictsnmatching the target serial are rewritten to the VP serial; AMS-hardware serials (n3f/0.snetc.) are left alone — those identify physical AMS units, not the device. (4)ipcam.rtsp_urlis left unchanged: BambuStudio overrides the URL host with the device IP it bound on (the VP), so the slicer hits the VP's :322 RTSPS port — not the printer's directly. (5) For the slicer's RTSPS to reach the printer, the VP gets a rawTCPProxyon<bind_ip>:322 → <printer_ip>:322(same approach proxy mode uses;cap_net_bind_servicewas already in the systemd unit for FTP :990). (6)extrusion_cali_getis forwarded — answering it locally hides the user's stored k-profiles. Setup nuance for camera: because the slicer authenticates against the printer's RTSPS with whatever access code is in its profile, the VP's access code must match the target printer's access code for the camera path to authenticate. This is a one-time configuration step (Settings → Virtual Printer → set access code = target printer's LAN code, then re-add the VP in Bambu Studio / Orca Slicer). MQTT and FTP work either way; only camera needs the match because RTSPS auth happens between the slicer and the real printer's broker. Tested e2e with both BambuStudio and OrcaSlicer against H2D (dual-nozzle, AMS 2 Pro + AMS HT) and X1C (single-nozzle, AMS) across all three non-proxy modes (Immediate / Review / Print Queue) — sync, send, k-profile lookup, AMS configuration from slicer, and live camera all work. Files: newbackend/app/services/virtual_printer/mqtt_bridge.py(caches push_status / get_version, forwards slicer commands, fans out command responses, rewrites identity fields includingnet.info[*].ipLE uint32);bambu_mqtt.pygainsregister_raw_message_handler/unregister_raw_message_handler/publish_rawso the bridge can subscribe to Bambuddy's existing per-printer paho subscription without opening a second session;mqtt_server.pyswitches_send_status_reportand_send_version_responseto cached-as-base when the bridge has data, falls back to the original synthetic stubs otherwise;manager.pywires the bridge + a rawTCPProxyfor RTSPS intostart_serverfor non-proxy modes whenever a target printer is configured. 25 new tests intest_vp_mqtt_bridge.pypin the contract: lifecycle, push_status caching, serial / IP rewriting, get_version-modules cache, selective fan-out (only command responses, never push_status itself), wire format must useindent=4, routing of slicer-issued commands (project_file / gcode_file local; everything else forwarded), and the IP-encoding helper against captures from real H2D pushes. Proxy mode is untouched —SlicerProxyManagerstill owns its own MQTT/FTP/RTSP/Bind/Aux proxies in proxy mode and never instantiatesSimpleMQTTServerorMQTTBridge. -
AMS slot Load / Unload from the printer card (#891, reported by @NNeerr00, +1 from @cadtoolbox) — The MQTT primitives for "load filament from a tray" and "unload the currently loaded tray" already existed in
bambu_mqtt.py(reverse-engineered from BambuStudio captures, including the H2D dual-extruder right-external case captured fresh during this work) but were unused — there was no HTTP route and no UI. Net effect: every Load / Unload had to happen on the printer touchscreen, and external-spool users on dual-nozzle H2D had no way to drive Ext-R from the desktop at all. Backend: newPOST /printers/{id}/ams/load?tray_id={int}andPOST /printers/{id}/ams/unload, both gated onPermission.PRINTERS_CONTROL. The load route validatestray_id ∈ {0..15, 254, 255}(AMS slots, single-external/Ext-L, Ext-R respectively) and returns a human-readable target in the success message ("AMS 0 slot 1", "external spool", "Ext-R") so the UI toast tells the user which spool the printer is now feeding from. MQTT primitive update:ams_load_filamentgains a third encoding branch fortray_id=255matching the BambuStudio capture verbatim —ams_id=255, slot_id=0(the right-extruder index, not a slot index — Bambu's load command on dual-extruder externals encodes the destination extruder, not the source slot),target=255, andcurr_temp = tar_temp = right-nozzle temp(read fromstate.temperatures["nozzle_2"], falling back to 215 °C if the right nozzle is cold or unknown — the printer rejects nonsensical temps, so a warm fallback is safer than-1). The existingtray_id=254branch is preserved verbatim (slot_id=254, curr/tar=-1) since that came from a single-extruder capture and is known to work; no risk of regression on existing single-external setups. UI: the existing AMS slot popover (the one with "Re-read RFID") gains two new entries — "Load" (poststray_id = ams.id * 4 + slotIdx) and "Unload" (no params, global on the currently-loaded slot). The external spool slot — which had no popover at all before — gets one with the same Load + Unload entries, and on dual-nozzle H2D each external slot (Ext-L tray_id=254, Ext-R tray_id=255) drives its own extruder. The menu is hidden whilestate === 'RUNNING'(parallels the existing RFID re-read gating). i18n:printers.ams.load,printers.ams.unload, plus four new toast strings (loadInitiated,unloadInitiated,failedToLoad,failedToUnload) added to all 8 locales — English fully translated, German fully translated, the other 6 locales seeded with English copy pending native translation (matches the project's existing flow for newly-added user-facing features). 16 new tests pin the contract: 5 unit tests intest_bambu_mqtt.py::TestAmsLoadFilamentEncoding(AMS slot encoding, Ext-L preserves legacy capture, Ext-R uses the new captured shape with actual right-nozzle temp, Ext-R falls back to 215 °C when cold, disconnected client doesn't publish); 11 integration tests intest_printers_api.py::TestAMSLoadUnloadAPI(load: invalid tray_id 400, not-found 404, not-connected 400, AMS slot success with derivedams_id*4+slotmath, Ext-L success, Ext-R success, MQTT failure 500; unload: not-found, not-connected, success, MQTT failure 500); 4 frontend tests inPrintersPageAmsLoadUnload.test.tsx(Load posts the right tray_id, Unload posts with no params, menu hidden while RUNNING, external spool's tray_id=254 round-trips through the route). -
API keys can read Bambu Cloud presets on the owner's behalf (#1182, reported by @turulix) — Tim is building a fully automated headless slicing pipeline against Bambuddy's API and hit the wall flagged in the previous round of cloud-auth work (#665):
/cloud/*routes resolvecloud_tokenper-user fromUser.cloud_token, but the auth gate (require_permission_if_auth_enabled,auth.py:856) returnedNonefor API-keyed requests, so the route fell back to the globalSettings-table token, which only carries a value in auth-disabled deployments. Net effect on auth-enabled deployments: API keys reached the gate just fine, then/cloud/filamentsalways sawuser=None, calledget_stored_token(db, None)against an empty Settings table, and returned 401 / empty results — no path to read the slicer presets, filament catalogue, or device list that a CLI workflow needs. The data model treated API keys as standalone tokens with no owner (APIKeyhadid,name,key_hash, scope flags, andprinter_ids— nouser_id), so even if the gate wanted to delegate the cloud lookup, there was no User to delegate to. The fix: make API keys carry an owner, route /cloud/* lookups through that owner, and gate the new capability behind an explicit opt-in scope so existing automation doesn't gain cloud-read access on upgrade. Concretely: (1)APIKeygainsuser_id(FK tousers.id, ON DELETE CASCADE — Postgres enforces, SQLite plus an explicitDELETE FROM api_keys WHERE user_id = ?in the user-delete route since SQLite ships FK enforcement off; the project's existing pattern atusers.py:397-406forcreated_by_idcleanup) andcan_access_cloud(BOOLEAN DEFAULT 0 — opt-in, never set on legacy rows). (2) The auth gate now returns the owner User when it validates an API key withuser_idset, so/cloud/*routes naturally resolveuser.cloud_tokenthe same way they do for JWT-authed sessions. Permission semantics are preserved — API keys still bypass the per-route permission check (their scopes live on the row itself), the User return is only so cloud-aware routes can read per-user state. Legacy ownerless keys (user_id IS NULL) keep returning None, stay anonymous, and continue working against every non-cloud route exactly as before. (3) A router-level dependency on the/cloud/*APIRouterenforces three independent fences for API-keyed callers:user_id IS NOT NULL(legacy keys → 401 with "recreate it from Settings → API Keys" — explicit recreate path rather than silently degrading),can_access_cloud=True(otherwise 403 with "Enable 'Allow cloud access' on the key"), andbuild_authenticated_cloudreturning a service (otherwise 401 with the existing token-not-set error — unchanged for JWT flow). The router-level dep duplicates the API-key validation done by the regular auth gate (router-level deps run before route-level deps in FastAPI, sorequest.stateisn't populated yet) — the cost is one extraSELECT FROM api_keysper cloud request, bounded and cheap with thekey_prefixindex. (4) The create route stampsuser_id = current_user.idfrom the creator and rejectscan_access_cloud=Truewhen auth is disabled (no per-usercloud_tokenstorage exists in that mode — fail loudly at create time rather than silently producing a non-functional key). PATCH route rejects flippingcan_access_cloudto True on a legacy ownerless key for the same reason — force recreate. (5)APIKeyResponseexposesuser_idso the UI can show ownership at a glance: a "Cloud" badge for cloud-enabled keys and a "Legacy" badge with hover tooltip ("Created before per-user ownership; recreate to use cloud access") for ownerless rows. The form gains an "Allow cloud access" checkbox, default off. Migration: two idempotentALTER TABLE api_keys ADD COLUMN(user_id INTEGER REFERENCES users(id) ON DELETE CASCADEandcan_access_cloud BOOLEAN DEFAULT 0) plus an index onuser_idfor the auth-gate's owner→keys lookup that runs on every API-keyed request. i18n: 5 new keys (settings.cloudAccess,settings.cloudAccessDescription,settings.cloudBadge,settings.legacyKey,settings.legacyKeyTooltip) added to all 8 locales — English fully translated, German fully translated, the other 6 locales seeded with English copies pending native translation (matches the project's existing flow for newly-added user-facing features). 9 backend integration tests intest_api_key_cloud_access.py: create stamps owner + cloud flag, defaults off when not asked for, rejected when auth disabled (no per-user storage), PATCH rejected on legacy keys; cloud router rejects legacy keys with the recreate copy, rejects owned-but-no-cloud-flag keys with the enable-cloud-access copy, lets owned-and-flagged keys through with owner'scloud_tokenin the response, JWT callers unaffected (gate is no-op for non-API-keyed); user-delete CASCADEs the API keys via the explicit DELETE in the route. 2 frontend SettingsPage tests pin the badge rendering matrix (Cloud badge present oncan_access_cloud=true, Legacy badge present onuser_id=null, neither rendered on a normal owned non-cloud key) and the create-form contract (toggling "Allow cloud access" results incan_access_cloud=truein the POST body). Permission semantics for the new fence are the only behavioural change for existing API keys: keys created before this release become "legacy" rows and are rejected at /cloud/* with the recreate message; every other endpoint they were used against — queue, status, control — is untouched. -
Home Assistant addon detection — Settings → Updates and the in-app update banner now defer to the HA Supervisor (#1167, reported by @Spegeli) — Bambuddy already shipped
HA_URL/HA_TOKENenv-var support specifically labelled "for HA Add-on deployments" (#283) and a community-maintained HA addon (hobbypunk90/homeassistant-addon-bambuddy) exists upstream, so an HA-supervised installation is a real first-class deployment shape. Until now though, the update UI didn't know about it: HA addon users got the same "Update available!" banner as everyone else and, if they clicked through to Settings, saw the docker-compose snippet ("docker compose pull && docker compose up -d") which they cannot run from inside an HA addon container — that's the Supervisor's job. Detection uses the canonical signal: HA Supervisor injectsSUPERVISOR_TOKENinto every addon container, and that variable is not set in any other environment. A new_is_ha_addon()helper inbackend/app/api/routes/updates.pyflips a request-level boolean which/updates/checksurfaces asis_ha_addon: bool+ an extendedupdate_method: 'git' | 'docker' | 'ha_addon'enum. The check is checked before Docker on/updates/applybecause HA addons are Docker containers — checking docker first would mis-classify them and serve the wrong message; the response also keepsis_docker: truealongsideis_ha_addon: trueso older frontend bundles still hit a managed-deployment branch (degrading to the Docker UX) instead of rendering an in-app Install button that can't work. Frontend branches identically:SettingsPage.tsx's update card checksis_ha_addonfirst and renders "Updates are managed by the Home Assistant Supervisor. Open Settings → Add-ons → Bambuddy in Home Assistant to install the new version." in place of the docker-compose hint;Layout.tsx's update banner is suppressed entirely for HA addons since the HA Supervisor's own update notification already surfaces the new version natively in the HA UI and a duplicate Bambuddy banner would just be noise that links to a page that says "go to HA". Plain Docker deployments are unaffected — the existing docker-compose hint and the in-app banner still render the same way they did. Localised across all 8 UI languages (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW) with full translations of the newsettings.updateViaHomeAssistantstring. 6 new tests pin the contract: 3 backend unit tests for_is_ha_addon()(env var present → true, absent → false, empty string treated as unset to guard against shells that export it empty), 1 backend integration test for the HA-precedes-Docker rejection on/updates/apply(asserts the message says "Home Assistant" and not "Docker Compose"), 2 backend integration tests for/updates/checkcovering the HA-addon branch (update_method == "ha_addon", both flags true) and the plain-Docker branch (is_ha_addon: false,update_method == "docker"); 2 frontend SettingsPage tests pin the mutually-exclusive UI rendering (HA branch shows the HA copy and not the docker-compose snippet; Docker branch shows the snippet and not the HA copy, neither shows the Install button); 2 frontend Layout tests pin the banner suppression for HA and its retention for plain Docker. -
OIDC auto-created users now get readable usernames and land in a configurable group (#1173) — Two improvements to the OIDC auto-create flow: (1) Username derivation: Bambuddy now derives the username from
preferred_username, thenname, before falling back to the opaqueprovider_sub[:30]. Each candidate is sanitized independently — alphanumeric plus./-/_, whitespace collapsed, deduplication suffix appended on collision — so a value that strips to empty (e.g."!!!") correctly falls through to the next option rather than silently producing"oidcuser". (2) Default group: each OIDC provider gains adefault_group_idfield. When set, auto-created users are placed in that group; when unset, the existing "Viewers" fallback is preserved, so behaviour is unchanged for existing deployments. The column is nullable withON DELETE SET NULL; SQLite does not enforce FK constraints here, so a deleted configured group falls through to Viewers at runtime.default_group_idis validated on create/update (422 on a non-existent group). Exposed in the OIDC settings form as a group dropdown. Limitation: to clear a configured default group, delete the group or select a different one — explicit reset-to-null is not currently supported. -
Filament Track Switch (FTS) support — print modal filament dropdown is no longer empty when an X2D / H2D has the FTS accessory installed (#1162, reported by @mkavalecz) — When the FTS accessory is installed the printer's MQTT changes one nibble of the per-AMS
infobitmask: bits 8-11 flip from a fixed extruder ID (0x0 / 0x1) to0xE("uninitialized"), because the AMS is no longer wired to a single nozzle — the FTS dynamically routes any slot to either extruder. Bambuddy's MQTT parser already skipped 0xE entries when buildingams_extruder_map(matching BambuStudio's reading for boot-time transient state), so with the FTS installed the map ended up empty and the print modal's filament dropdown — which filters byextruderId === nozzle_idto prevent cross-nozzle assignment ("position of left hotend is abnormal" failures) — filtered out every loaded slot. Net effect: empty Filament Mapping dropdown on every dual-nozzle print with the FTS, even when the AMS was fully loaded with the right material. Detection comes from a new MQTT field —print.device.fila_switch— which is non-null only when the accessory is installed; it carries the routing topology as two arrays:in[track] = currently fed slot (-1 = empty)andout[track] = extruder this track terminates at. The fix surfaces this through a newFilaSwitchStatedataclass onPrinterState(installed,in_slots,out_extruders,stat,info) and the equivalentFilaSwitchResponsePydantic schema on theGET /printers/{id}/statusroute. Frontend (useFilamentMapping.ts+FilamentMapping.tsx) skips the per-extruder filter whenprinterStatus.fila_switch?.installed === trueso any compatible AMS slot can satisfy any nozzle's filament requirement, since the FTS handles the routing. Slots currently fed into a track also get a routing badge in the dropdown —[L]or[R]— so the user can tell at a glance which slot the FTS is currently routing where (idle slots get no badge: they can be routed to either extruder on demand). The hard "no cross-nozzle assignment" filter on real dual-nozzle printers without the FTS stays untouched (still trips the same way it always has —fila_switch == nullkeeps the existing behaviour). 4 backend tests intest_bambu_mqtt.py::TestFilamentTrackSwitchDetection(default-not-installed, detect-from-MQTT-using-the-reporter's-bundle, no-fila_switch-field-stays-not-installed, missing-in-out-arrays-don't-crash) and 2 frontend tests inuseFilamentMapping.test.ts(FTS-active drops the nozzle filter; explicitfila_switch: nullkeeps the filter applied). Upstream fila_switch payloads with anything other than the documented shape are tolerated —installedflips on the presence of the field, the routing arrays default to empty lists if missing, and the dropdown skips the badge for slots not currently inin_slots.
Fixed
-
Docker permission errors on
/app/data/virtual_printerand similar paths — root-owned volumes / bind-mount sources no longer break virtual printer setup (#1211 follow-up; same shape as multiple previous user reports) — Two related failure modes have been biting Docker users repeatedly: (1) Docker named volumes are created by the daemon asroot:rootand the previouschmod 777 /app/dataDockerfile workaround only covered the named-volume root, so subdirs Bambuddy creates at runtime (virtual_printer/uploads,virtual_printer/certs, etc.) inherited the wrong ownership when the container ran as1000:1000; (2) the shippeddocker-compose.ymlships./virtual_printer:/app/data/virtual_printeruncommented, and dockerd creates a missing bind-mount source on the host as root before the container starts — leaving the host directory unwritable by uid 1000 inside the container even though the named volume above it had the chmod-777 workaround. Symptom either way:[Errno 13] Permission denied: '/app/data/virtual_printer/uploads', no virtual printer ever starts, "VP doesn't work" support reports follow. Fix: newdeploy/docker-entrypoint.shruns as root, normalises ownership of/app/dataand/app/logs(and/app/data/virtual_printerwhen bind-mounted) toPUID:PGID(default1000:1000, overridable via env), then drops to that uid viagosubefore exec'ing uvicorn. The chown is gated behind a top-level ownership check so subsequent restarts skip the recursive traversal entirely (no multi-second startup penalty on multi-GB archive dirs). A sentinel.bambuddyfile in each data path prevents Docker from re-syncing image directory metadata on every mount (otherwise empty volumes have their ownership reverted from the image on each restart, defeating the idempotency). When the container is started with an explicituser:directive in compose or--userondocker run, the entrypoint detects it isn't running as root and falls through to direct exec without modifying ownership — preserving compatibility with users who pin a specific uid. Compose template changes: theuser: "${PUID:-1000}:${PGID:-1000}"line is removed (entrypoint owns privilege drop now);PUID/PGIDenv vars added with the same defaults;./virtual_printer:/app/data/virtual_printerbind mount commented out by default with a clearer explanation of when it's actually needed (only when sharing the VP CA certificate with a co-located native install, which most Docker-only users don't have). Existing users with that bind mount uncommented continue to work — the entrypoint chowns the host-side directory through the bind mount the first time it sees the wrong ownership, fixing #1211 specifically. Tested end-to-end against four scenarios on a clean rebuild: (a) named volume only with default PUID/PGID; (b) explicit--user 1000:1000override (entrypoint falls through); (c) customPUID=1500; (d) legacy stale root-owned volume contents from a pre-fix install (gets normalised on first start). Idempotency verified: chown messages appear on first start, subsequent starts are silent. -
Backup restore silently lost most data — settings reverted to defaults, ~most printers/archive rows missing (#1211, reported by @Carter3DP; same shape as previously-closed #668) — Restoring a settings backup ZIP appeared to succeed but the user found their
energy_cost_per_kwhreverted to the0.15default (defined inmain.py:3457), 7 of 8 printers gone, 1 GB of archive files on disk but only 1 archive row in the database. #668 was closed in March without an actual fix — that user happened to make it work by rolling back to a stable release, which masked the bug; same shape resurfaces here on a single (consistent) version. Cause: the live database runs in WAL mode (PRAGMA journal_mode = WALindatabase.py:19). The original restore endpoint usedshutil.copy2(backup_db, db_path)afterengine.dispose(). Two things conspired to make this unsafe: (1) anything the fresh container wrote between startup and the restore call —seed_default_groups,init_db()migrations, background heartbeat writes — sits inbambuddy.db-walwith valid checksums, andengine.dispose()doesn't checkpoint it; (2) FastAPI's dependency injection keeps the route handler's owndb: AsyncSession = Depends(get_db)session checked out acrossengine.dispose()(per SQLAlchemy docs, dispose only closes pooled — not checked-out — connections), so the WAL inode is held open through the whole restore. Aftershutil.copy2rewrote the main DB inode in place, SQLite's WAL recovery on the nextinit_db()happily re-applied the stale frames on top of the restored content, partially clobbering it with fresh-install state. Initial fix attempt of "delete the WAL/SHM/journal sidecars before the copy" turned out to be insufficient — verified experimentally that the still-open request session reads the unlinked sidecars via held fds and bleeds the WAL state back into the new file when it eventually closes. Real fix: replace the file copy with SQLite's online backup API (src_conn.backup(dst_conn)). The page-by-page protocol opens both DBs as proper SQLite connections, acquires the right locks, and routes new pages through the destination's own WAL — concurrent open sessions see their own transactional snapshot until they close (transaction isolation) but can't corrupt the restored state. Verified via 6 regression tests inbackend/tests/unit/test_restore_sqlite_wal_safety.py: the buggyshutil.copy2path is pinned (the test asserts the bug manifests under the un-checkpointed-WAL condition, so a future "small simplification" can't silently re-introduce it); the productionsrc_conn.backup(dst_conn)path returns the user's restored values exactly under the same bug condition; the no-WAL-frames case (fresh container, restore as the very first action) round-trips cleanly; and the page-protocol parametrised test runs at 1, 100, and 1000-page DB sizes so a regression at any one size surfaces. PostgreSQL path (_import_sqlite_to_postgres) is unchanged — that's row-by-row already and was never affected. -
formatTimeOnlytests failed under non-:-separator locales (#1213, reported by @maugsburger) — Running the frontend test suite underLC_ALL=en_DK.UTF-8(or any locale whosetoLocaleTimeStringuses a separator other than:) failed two tests infrontend/src/__tests__/utils/date.test.ts:formats time with 12h format(expected02.30 pmto match/2:30|02:30/) andformats time with 24h format(expected14.30to contain14:30). The implementation is correct —formatTimeOnlycallsdate.toLocaleTimeString([], …)which by design respects the user's locale, so a Danish-English user genuinely should see02.30 pmin the UI. The tests just hard-coded the:separator. Fix: test assertions now use\D+(any non-digit, one or more) for the separator:expect(result).toMatch(/\b0?2\D+30\b/)andexpect(result).toMatch(/\b14\D+30\b/). Tests the actual contract — "the function returns hours and minutes, separated somehow" — without coupling to a specific separator that varies by locale (en_DK uses., some en_* locales use a narrow no-break space at U+202F, most others use:). Verified passing underen_DK.UTF-8,en_US.UTF-8, andde_DE.UTF-8. Audited every othertoLocaleTimeString/toLocaleStringcall site in the test suite — no other places hard-code separator characters;formatETA,formatDateInputetc. assert viatoBeTruthy()or check translated content. -
SpoolBuddy kiosk screen-blank timeout setting was ignored after the first save (reported by maziggy) — Picking a new "Screen Blank Timeout" in SpoolBuddy Settings → Display didn't change the actual blanking behaviour: whatever value was active when the kiosk last booted continued to fire — a user who started with the 10 m preset and then switched to 1 m, 5 m, or "Off" still saw the screen blank at 10 m forever. Cause: blanking is driven by
swayidle, started once byspoolbuddy/install/spoolbuddy-idle.shat labwc autostart with the timeout passed as a command-line argument (swayidle -w timeout $T 'wlopm --off' resume 'wlopm --on'). The script fetchedblank_timeoutfrom the backend exactly once at startup andswayidlehas no runtime control surface for changing its timeout. The Python daemon'sdisplay.set_blank_timeout()updated an in-memory variable on the daemon side that was only used for daemon-side idle bookkeeping (tick()log-line) and never reachedswayidle, so UI changes were silently discarded until the next kiosk restart. Documented as such in the daemon's docstring (display_control.py:5: "swayidle is the sole authority on screen blanking") — the architecture predicted the bug, the UX never matched. Fix: the wake FIFO at/tmp/spoolbuddy-wakenow carries a second message in addition towake:reload-timeout N. The daemon writes it wheneverset_blank_timeout()is called with a value that differs from the current one (the very first call is suppressed because the watchdog already fetched the same value at its own startup — signalling there would just thrashswayidleon every cold start). The watchdog script's FIFO loop is restructured aroundstart_swayidle/stop_swayidlehelpers and a singlecasestatement that dispatches on the message:wake→wlopm --on+ arm a re-blank at the current timeout;reload-timeout N→ kill the runningswayidle, setTIMEOUT=$N, restartswayidle, andwlopm --onso the user sees the change took effect even if the screen was already blanked. The script de-dupes too — areload-timeout NwhoseNmatches the current value is a no-op, so the daemon's local de-dupe and the script's de-dupe both guard against thrash. Going from any positive timeout to0("Off") correctly stopsswayidleand never restarts it, going from0to a positive value starts a freshswayidle— both work without a kiosk restart. The script's main loop opens the FIFO read+write (exec 3<>"$WAKE_FIFO") so the bashreadnever sees EOF when the daemon momentarily disconnects between writes (without that, the loop would exit the first time the daemon closed its write end). Acleanuptrap onTERM/INT/HUPstopsswayidle, removes the FIFO, and exits cleanly. 7 new tests inspoolbuddy/tests/test_display_control.py::TestDisplayControlFifoMessagespin the daemon side of the protocol against a real FIFO intmp_path:wake()writes the literalwake\nline; firstset_blank_timeoutis suppressed (script already has the right value); subsequent change emitsreload-timeout N\n; identical-value calls don't signal; transitioning to0emitsreload-timeout 0\n(covers "user picks Off after enabling"); negative inputs are clamped to0in the signal payload; missing-FIFO writes are silent no-ops (kiosk-not-running case). Also handles the SpoolBuddy0schema default — the firstset_blank_timeout(0)call from a fresh daemon doesn't signal (init suppressed) so no spurious thrash on a never-configured device. -
Archive 3MFs (and library file bytes) silently deleted from disk on every print completion (#1212, reported by @abbasegbeyemi; matches private "file disappeared overnight" reports) — Reprint and View G-code on a freshly-completed archive returned 404 with no log line explaining why; the DB row was intact, the archive grid kept showing the entry, but
archive.file_pathpointed at a path that no longer existed on disk. Same shape independently reported by a daily-build user whose.gcode.3mf"disappeared by itself overnight" between Saturday's print and Monday morning's reprint attempt. Root cause was a regression introduced by #1166's cover-cache pre-population: the dispatch sites inbackground_dispatch.py:692,background_dispatch.py:896, andprint_scheduler.py:1897started caching the live archive copy (and library file bytes for the Direct-Print flow) in the shared 3MF download cache so the/coverendpoint could skip a redundant FTP transfer to the printer mid-print. The cache itself was originally designed for transient downloads underarchive_dir/temp/andclear_3mf_cache(printer_id, delete_files=True)— called fromon_print_completeto keep that temp dir from accumulating — happilyunlink()'d every cached path. Pre-#1166 every cached path was a temp file, so deletion was correct. Post-#1166 the cleanup was destroying user data: every print → archive 3mf cached → on print completeclear_3mf_cachewalks the cache →path.unlink()on the actual archive copy. ThePath.exists()guard inside_maybe_unlinkmasked the failure: the file existed at unlink time, so no exception, no warning, just silent destruction. The DB row remained, so the UI listing didn't change — only when the user tried to act on the archive (reprint / view-gcode / re-export) did the missing file surface as a 404. Affected every daily build since889c8bd8(Apr 29). Fix:clear_3mf_cache._maybe_unlinkinbackend/app/services/bambu_ftp.pynow refuses tounlink()any path outsidearchive_dir/temp— the cache dict is still cleared either way (so re-cache logic continues to work and the cover endpoint still hits a fresh path on the next print), only the on-disk delete is gated. Persistent locations —archive/<printer_id>/...,archive/unassigned/...(VP-archived prints withprinter_id=None),library_files/..., and anyis_externallibrary mount — survive intact. The dispatch sites that cache those paths are unchanged: it's correct for/coverto read straight from the live archive copy and avoid the redundant 36 MB FTP transfer; the only bug was the cleanup branch treating all cached paths as transient. Regression testtest_clear_does_not_delete_persistent_filesintest_bambu_ftp.pypins the contract end-to-end: an archive 3mf atarchive/1/.../...gcode.3mf, a library 3mf atlibrary_files/..., and a temp 3mf atarchive/temp/...are all cached for the same printer; afterclear_3mf_cache(1)runs, all three cache entries are dropped from the dict (so the cache state is consistent), but only the temp file is unlinked from disk — the archive and library files still exist. Two existing cache tests (test_clear_by_printer_scoped,test_clear_without_deleting_files) updated to put their fixtures underarchive_dir/tempsince that's now the only path the cleanup will touch. Damage: users on daily builds since Apr 29 with aprint → wait for completion → reprint or view-3mf laterworkflow have been silently losing archive copies. Recovery for individual users: re-import the source 3mf from your slicer / NAS, or re-archive from the printer's FTP if the file is still there. Going forward the bytes are safe. -
MakerWorld P2S 3MFs failed to slice with "Param values in 3mf/config error: -1 not in range" (#1201, reported by @inorichi) — Slicing any MakerWorld model sliced for the P2S (e.g.
https://makerworld.com/en/models/1958872) bombed withSlicer process failed (exit code 238)and stderr listingraft_first_layer_expansion: -1 not in range [0.0, 3.4e+38]andtree_support_wall_count: -1 not in range [0.0, 2.0]. Root cause: BambuStudio writes"-1"intoMetadata/project_settings.configfor fields the user wants inherited from the parent process preset — the GUI handles this internally, but the headless CLI (orca-slicer-api / bambu-studio-api sidecar) runsStaticPrintConfig's range validator against the embedded settings before the--load-settingsoverrides apply, so the sentinel"-1"trips the field's lower-bound check and the CLI exits non-zero before our profile triplet is ever consulted. Theslice_with_profilespath failed; the fallback toslice_without_profiles(which uses embedded settings only) also failed because it reads the sameproject_settings.configand the same validator runs there too. Earlier in the codebase there's a_strip_3mf_embedded_settingsfunction that tried to dodge this by removing the entireproject_settings.config(plusmodel_settings.config,slice_info.config,cut_information.xml); that experiment was reverted because the strip brokeStaticPrintConfiginitialisation — silent exit-0, noresult.json, no stderr, masked by the fallback retry which then produced wrong-printer output without telling anyone (the cautionary comment inlibrary.py:_run_slicer_with_fallbackrecords the lesson). Fix is surgical: new_sanitize_project_settings_sentinels(zip_bytes)opens the embedded config, removes only allowlisted keys when their value is exactly"-1", and re-zips. Allowlist (_PROJECT_SETTINGS_SENTINEL_KEYS) starts with the two from this report (raft_first_layer_expansion,tree_support_wall_count) plusprime_tower_brim_width(a known sentinel cited in the strip-experiment comment block from earlier reports). Other fields — including non-allowlisted keys that happen to hold"-1"(e.g.z_offsetset to-1deliberately by a user) — are left untouched, so a blanket "-1 strip" can't silently corrupt legitimate negative values. The sanitiser runs before both the profile-driven path and the embedded-settings fallback, since both fail on the same input. Defensive fallbacks: returns the original bytes unchanged when the input isn't a valid zip, doesn't containproject_settings.config, has no allowlisted sentinels present, the JSON is malformed, or the config root isn't a dict — so the caller can pass the result on without further checks. Geometry, thumbnails, color, multi-part data, and every other zip entry round-trip byte-identical (the previous full-strip experiment's failure mode can't reoccur). 13 new unit tests intest_project_settings_sentinel_sanitiser.pypin the contract: each allowlisted key removed when value is"-1"(parametrised across the allowlist); multiple sentinels removed at once; allowlisted key with legitimate non-sentinel value ("0") preserved; non-allowlisted key holding"-1"(z_offset) preserved; identity return when nothing needs sanitising; array-form values (per-filament/per-extruder lists) left alone (v1 handles scalar strings only, expand later if needed); other zip entries (model_settings.config, slice_info.config, _rels metadata, geometry) all preserved with byte-identical content; non-zip input passes through; missingproject_settings.configpasses through; malformed JSON passes through; non-dict JSON root passes through. Adding new sentinel keys: if a future report surfaces another field name in the slicer's<field>: -1 not in range [...]error, add the field to_PROJECT_SETTINGS_SENTINEL_KEYS— the rest of the code stays unchanged. -
Archive created with wrong plate metadata when consecutive plates of the same model are printed back-to-back (#1204, reported by @BurntOutHylian) — Print Plate 2 of any multi-plate project, let it complete, then immediately print Plate 1: the resulting archive was named "MyModel - Plate 2" with Plate 2's filament slots and slicer estimate, even though Plate 1 was the print actually running. Root cause was an MQTT lag in the
print_startdata: the trigger fires on agcode_filechange (bambu_mqtt.py:2781-2786— the field carrying/data/Metadata/plate_N.gcode, which is plate-specific and always fresh), butsubtask_name(model-level, e.g. "MyModel - Plate 2") can still echo the previous job in the same MQTT batch. The FTP candidate list inmain.py:1974is built fromsubtask_namefirst, so the previous Plate 2 upload — still resident on the printer's FTP from the just-completed print — got picked up and fed into archive creation. The 3MF parser then read_plate_index=2from the wrong file'sslice_info.configand locked Plate 2's name + estimate + per-slot filament data into the row at creation, with no follow-up to correct. Reporter @BurntOutHylian's diagnosis nailed it: the parser already extracts_plate_indexfrom inside the 3MF (archive.py:154), andparse_plate_id()(printer_manager.py:678) already extracts the plate fromgcode_file— those two values just weren't being compared. Fix: new helperspeek_plate_index_in_3mf()(cheap zip read ofMetadata/slice_info.configonly, returning the plate index) andswap_plate_suffix()(rewrites trailing " - Plate N" or "_plate_N" — both forms appear in real subtask_names, seetest_print_start_expected_promotion) inarchive.py. After a successful FTP download in_handle_print_start, the new validation block inmain.pypeeks the downloaded 3MF's plate index, compares againstparse_plate_id(filename), and on mismatch retries the FTP fetch with a correctedsubtask_name. If the retry finds a 3MF whose plate matches, the wrong file is dropped and the corrected one is used — archive name + estimate + slots all reflect the actual plate. If the retry can't find a matching file (or no swap is possible becausesubtask_namehad no plate suffix to swap), the wrong 3MF is dropped and the existing no-3MF fallback (main.py:2155) creates an archive without metadata; the stalesubtask_nameis overridden to the corrected one (or cleared sofilenamewins) so the fallback'sprint_nameat least reflects the right plate rather than locking in a misleading name. The validation only fires whenparse_plate_id(filename)returns a value, so single-plate / non-Bambu / cloud-named jobs are unaffected. Defence in depth: the cache eviction is implicit —temp_path.unlink()makes the wrong-file cache entry self-clean on next access via the existingget_cached_3mfevict-on-miss path (bambu_ftp.py:660-664); no separate cache invalidation needed. 17 new unit tests intest_archive_plate_validation.pypin the helpers:peek_plate_index_in_3mfreturns the index for a valid 3MF, None for missing slice_info, None for missing index metadata, None for non-zip files, None for missing files, None for non-integer index values;swap_plate_suffixhandles the spaced "Plate N" form (capitalised + lowercase + tight-hyphen), the underscored "_plate_N" form (theBox3.0_(2)_plate_5case from the existing fixture), case-insensitive matching, returns None for names without a recognised suffix, returns None for None input, and preserves separator casing so the corrected name matches what BambuStudio actually uploaded. -
SpoolBuddy kiosk screen never blanked while a load cell was producing noisy readings (reported during user testing) — A noisy HX711 / load-cell mount that bounced the reported weight by ≥50 g around its midpoint kept the kiosk display permanently lit. The wake gate in
spoolbuddy/daemon/main.py:scale_poll_loop(WAKE_THRESHOLD = 50) checked the absolute change againstlast_wake_gramsand, on every trip, advancedlast_wake_gramsto the new noisy reading — so the next bounce back also exceeded the threshold, fireddisplay.wake()again, and the screen never stayed off long enough for swayidle'swlopm --off HDMI-A-1to mean anything. Symptom in the field: ~3–30 s betweenWake signal sent via FIFOlog lines, exactly correlated with the bigger noise spikes, screen flicker-blanking and immediately turning back on. Diagnosis from a real device'sjournalctl -u spoolbuddy.service:scale/readingPOSTs every ~1 s (REPORT_THRESHOLD=2 g, so the load cell was reporting ≥2 g changes constantly) interleaved with periodic wake signals. Fix: the wake gate now requires the scale'sstableflag (True only when consecutive readings agree within 2 g over a 1 s window — already produced byScaleReader.read()and previously only forwarded as telemetry to the backend). Unstable noise can no longer fire wake AND can no longer poisonlast_wake_grams, since the threshold check + the assignment are both gated onstable. Real spool placements / removals produce a settled post-event reading and continue to wake the screen as intended. 3 new regression tests inspoolbuddy/tests/test_main.py::TestScalePollLoopWakeGating: noisy ±60 g unstable readings never wake (the original bug); a settled >50 g jump wakes; a noise burst between two settled readings doesn't poisonlast_wake_grams(asserts the second stable wake still fires from the original baseline rather than the noisy peak). -
Print-complete notification reported the slicer's pre-print estimate instead of the actual elapsed time (#1198, reported by @BurntOutHylian) —
_background_notificationsinmain.py:3434builtarchive_datafor the completion notification withprint_time_seconds(the slicer's estimate parsed from the 3MF at archive creation), andnotification_service.py:909-910then formatted that field straight into the{{duration}}template variable. Net effect: a print cancelled 2 minutes into a 3-hour estimate told the user "duration: 3h" — wrong by orders of magnitude for any cancellation, abort, slow first layer, or any print whose actual elapsed diverged from the slicer's guess. The companion fieldactual_filament_gramswas already scaled by progress for partial prints (line 3445), so filament was right while time was wrong. Theprint_startnotification uses a separate{{estimated_time}}variable (line 838), so{{duration}}semantically should always have meant "actual elapsed" — it was just being read from the wrong source. Two-part fix: (1)main.py:3434now computesactual_time_seconds = int((archive.completed_at - archive.started_at).total_seconds())from the persisted timestamps when both are present and the elapsed is positive, and adds it as a new key inarchive_data;notification_service.py:909-916prefersactual_time_secondsand falls back toprint_time_secondsonly when timestamps weren't recorded (so the notification still has something if the elapsed can't be derived). (2)main.py:3172adds"cancelled"to the set of statuses that getcompleted_atset whenupdate_archive_statusruns — pre-fix onlycompleted,failed,abortedgot a timestamp, butcancelled(Bambuddy queue UI cancellation, distinct from touchscreen-aborts which already setcompleted_at) was deliberately excluded for reasons that no longer hold. Audited everycompleted_atconsumer in backend (archives.py:80, 333-337, 768-770, 723-731, 1722-1813,main.py:3229,projects.py:1475, 1489) and frontend (PrintersPage.tsx:2854,QueuePage.tsx:1053,StatsPage.tsx:902); none rely oncompleted_at IS NULLto mean "this is a cancelled print" — the three explicit-status filters already restrict tostatus == "completed"and the rest arecompleted_at or created_atfallback expressions that gracefully accept either. Knock-on benefit: the statistics-totals aggregation atarchives.py:723-731(which currently adds the full slicer estimate to the total whencompleted_at IS NULL) now adds the actual elapsed for cancelled prints too — a 2-minute cancellation contributes 2 minutes instead of 3 hours. Existing cancelled rows in the DB stay withcompleted_at=NULL; only new cancellations going forward get the timestamp. 3 new regression tests intest_notification_service.py::TestNotificationVariableFallbackspin the contract:{{duration}}reflectsactual_time_secondswhen present (2m elapsed wins over 3h estimate), falls back toprint_time_secondswhen actual is missing (1h estimate still surfaced rather than "Unknown"), and surfaces "Unknown" when both are absent. -
Frontend served behind a path-prefixed reverse proxy (e.g.
/bambuddy/on Traefik / nginx / Cloudflare Tunnel) loaded a blank page (#1195, reported by @Spegeli, follow-up to #1167) — Vite's defaultbase: '/'emits absolute asset URLs in the builtindex.html(/assets/index-*.js,/assets/index-*.css,/manifest.json,/img/...,/sw-register.js), which assumes the SPA is always served at the host root. Behind any path-prefixed reverse proxy — Traefik with a path prefix, nginxlocation /bambuddy/, Cloudflare Tunnel with path routing, Synology / Unraid reverse-proxy panels — the browser then requests those absolute paths from the host root, the proxy doesn't see them, and the upstream serves either a 404 or HTML for an unknown path withContent-Type: text/plain/text/html; the browser logsRefused to apply style from '.../assets/index-*.css' because its MIME type is 'text/plain'and renders a blank white page. Two-line fix:frontend/vite.config.tssetsbase: ''so Vite's HTML transform rewrites every absolute asset reference to relative (./assets/...,./manifest.json,./img/...,./sw-register.js) — these resolve correctly against whatever subpath the document was served from.frontend/public/sw-register.jsis a public-dir file Vite copies as-is, so itsnavigator.serviceWorker.register('/sw.js')call is changed toregister('sw.js')(relative); the SW scope is automatically pinned to whatever subpath the document loaded from, which is exactly what every reverse-proxy-at-subpath user wants. Net effect: anhttps://example.com/bambuddy/deployment now loads correctly without any frontend rebuild on the user's side. Out of scope for this change: runtime API base detection —API_BASE = '/api/v1'infrontend/src/api/client.tsis still absolute, so API calls still go to the host root. This is intentional. The fix above closes the immediate "blank page" report; making the API base, React Router basename, PWA manifest scope, and service-worker scope all subpath-aware would mean rewriting how the SPA bootstraps and would touch PWA-install state, push-notification subscriptions, and deep-link reload semantics. The supported way to embed Bambuddy in Home Assistant remains the Webpage panel +TRUSTED_FRAME_ORIGINSpath documented in the wiki — Bambuddy reachable on a stable URL (HTTP for HTTP-only HA, HTTPS via your own reverse proxy for HTTPS HA / Nabu Casa / custom-domain), iframe-embedded via the HA dashboard. HA Ingress / addon-based subpath embedding (which would require the runtime path detection above) is not supported by core. Documented explicitly indocker.mdso users hit the right pattern first. -
iframe embedding from trusted origins (e.g. Home Assistant Webpage panel) no longer blocked (#1191, reported by @azurusnova) — Bambuddy ships strict anti-clickjacking headers (
X-Frame-Options: SAMEORIGINand CSPframe-ancestors 'none') by default, which protects internet-exposed deployments from being embedded by hostile sites. But it also broke a documented integration path: Home Assistant's Webpage dashboard panel embeds Bambuddy via<iframe>on a different origin (HA on:8123, Bambuddy on:8000), and the SAMEORIGIN value is port-strict, so even same-LAN trusted setups got "refused to connect". A newTRUSTED_FRAME_ORIGINSenv var takes a comma-separated list ofscheme://host[:port]origins; when set, the middleware dropsX-Frame-Options(modern browsers honorframe-ancestors, and the legacyALLOW-FROM <url>syntax is deprecated and inconsistent across vendors) and the CSPframe-ancestorsdirective becomes'self' <origin> <origin>.... The default — empty env var — keeps the strict'none'behavior, so Docker / bare-metal users without HA see no behavioural change. Origin validation happens at startup: onlyhttp://andhttps://are accepted, paths/query/fragments/wildcards are rejected with a warning (one bad entry doesn't take the deployment down — it's just dropped from the allowlist). Thegcode-viewerroute'sframe-ancestors 'self'(same-origin embed for the in-app gcode preview iframe) also includes the allowlist when configured, so HA users embedding Bambuddy can still open the gcode viewer modal. 16 new tests intest_security_headers.py: 12 unit tests for the env-var parser (empty / unset / single / multiple / whitespace / empty-segment / non-http scheme dropped / missing host dropped / path dropped / query+fragment dropped / wildcard dropped / trailing-slash kept) and 4 integration tests for the middleware (default-strict emits SAMEORIGIN + 'none', allowlist relaxes CSP and drops X-Frame-Options, /docs branch also honors the allowlist, other security headers like X-Content-Type-Options and Referrer-Policy are unaffected in both modes). Documented in the Docker env-var reference page on the wiki and in.env.example. -
Virtual Printer queue mode auto-dispatched onto the wrong colour when multiple compatible printers were available (#1188, reported by @EdwardChamberlain) — Sending a sliced 3MF to a queue-mode VP via Orca / Studio with auto-dispatch on caused Bambuddy to schedule the job onto a printer of the right model but the wrong loaded filament: a print sliced for matte white PLA would land on a printer with no white loaded, and the printer would start the job using whatever was the closest available match. Edward's diagnosis was exact (
virtual_printer/manager.py:325-326): the manual /api/v1/print-queue/ POST flow extracts the 3MF's per-slot filament requirements at queue-add time and writesrequired_filament_types,filament_overrides, andams_mappingon the resultingPrintQueueItem, so the scheduler's color-match enforcement (print_scheduler.py:512— keys onfilament_overrides[].force_color_match === true) actually runs. The VP queue-write path (_add_to_print_queue) skipped all of that and built a barePrintQueueItemwith onlyprinter_id,target_model,archive_id,plate_id,position,status,manual_start. Net effect: the scheduler reached the model-only-matching fallback and accepted the first available printer of the target model regardless of loaded colour, exactly as he described. Fix: the scheduler's existing_get_filament_requirements3MF parser is extracted into a shared helper (backend/app/services/filament_requirements.py:extract_filament_requirements) so the VP path can reuse it at upload time. The VP's_add_to_print_queuenow calls that helper after archiving and populatesrequired_filament_typesunconditionally (cheap; helps the scheduler reject obvious type mismatches even withoutforce_color_match); and writesfilament_overrideswithforce_color_match: trueper consumed slot when a new per-VP settingqueue_force_color_matchis on. Default is off to preserve current behaviour for upgraders — a fresh-install user who wants the bug-free behaviour flips the toggle once on the VP card; an existing user gets exactly the model-only-matching they had before until they opt in. Auto-dispatch onto the wrong material happens loudly enough that anyone affected can find the toggle. Why default-off rather than default-on: existing automation that relies on "send to queue VP, get printed somewhere" without caring about colour shouldn't silently start blocking on colour matching after an upgrade. The toggle has clear UI copy (virtualPrinter.queueForceColorMatch) explaining the trade-off. Defence in depth: a malformed or unparseable 3MF (e.g. fake bytes from a misconfigured upload tool) leaves both fields None and the scheduler falls back to model-only matching, matching pre-fix behaviour for the unhappy path. The scheduler itself is unchanged — it already handledforce_color_matchcorrectly when the field was populated; the bug was purely the VP path not populating it. Schema: one nullable columnvirtual_printers.queue_force_color_match BOOLEAN DEFAULT 0/FALSE(Postgres-safe) added via the existing_safe_executemigration pattern. API:VirtualPrinterCreateandVirtualPrinterUpdatePydantic schemas +_vp_to_dictresponse shape carryqueue_force_color_match, the create + update routes wire it through to the model, andVirtualPrinterInstanceconstructor +multiVirtualPrinterApiTypeScript client mirror the field. UI: new toggle onVirtualPrinterCardrendered only whenmode === 'print_queue'(parallels the existingauto_dispatchtoggle's mode-gating), withpendingActionstate for the in-flight indicator. i18n: newvirtualPrinter.queueForceColorMatch.{title,description}keys in all 8 locales — English fully translated, German fully translated, the other 6 locales seeded with English copy pending native translation (matches the project's existing flow for newly-added user-facing features). 11 new tests: 8 intest_filament_requirements.pycovering the extracted parser end-to-end (per-slot dicts, zero-use slots filtered, plate filtering, no-plate flat-walk fallback, unparseable / missing / config-less files, sorted output); 3 intest_virtual_printer.py::TestVirtualPrinterInstancecovering the VP write path (setting-off → onlyrequired_filament_typespopulated; setting-on →filament_overridespopulated withforce_color_match: trueper slot; unparseable 3MF → both fields None, no crash). Existing scheduler tests still pass against the refactored helper (verified end-to-end across the scheduler / virtual_printer / print_queue / filament test suites — 479 tests). Edward's "out of scope nice-to-have" suggestion of a "Requires Color Match" pill on queue cards is deferred to a follow-up so this PR stays scoped to his repro. -
Slicing a library file via API key fails with "no Bambu Cloud session is stored" even when the key has cloud access (#1182 follow-up, reported by @turulix) — Tim shipped the headless slicing pipeline #1182 was filed for, then hit a second wall:
GET /api/v1/cloud/settingsreturned the cloud preset IDs correctly (the/cloud/*router-level gate from #1182 was doing its job), butPOST /api/v1/library/files/{id}/slicewith those IDs in the request body failed the slice job witherror_status: 400, error_detail: "Cloud preset selected for printer, but no Bambu Cloud session is stored. Sign in to Bambu Cloud and retry."Cause: the/cloud/*fix routes the API key's owner User throughcloud_caller(a router-level gate stashes the owner onrequest.state.api_key_owner, route-level deps pull it back out), but the slice route lives on/library/*— different router, no gate, so when the auth dep returnedNonefor the API-keyed request the slice route passedcurrent_user_id=Nonestraight through to_run_slicer_with_fallback→_resolve_cloud(db, user=None)→get_stored_token(db, None), which falls back to the auth-disabled globalSettingstable. That table is empty in auth-enabled deployments, so cloud preset resolution failed even though the key's owner User had a perfectly validcloud_tokenon their User row. Fix is a new route-level depresolve_api_key_cloud_ownerincloud.pythat's permissive (returns the owner User if the key hascan_access_cloud=true, otherwise None — never raises) so it can be safely added to non-/cloud/*routes without breaking the local-presets path: a request with an API key that lacks the cloud scope still slices fine against local presets, and only fails with the existing "no Bambu Cloud session" error if it actually selects a cloud preset. Wired intoPOST /library/files/{id}/slice(Tim's blocker) andGET /slicer/presets(the SliceModal preset dropdown source — same root cause, would have hit anyone using the UI through an API-keyed reverse proxy). Both routes now resolve the cloud-token owner viacurrent_user or api_key_cloud_ownerinstead ofcurrent_user.id if current_user else None. The auth gate's None-return for API keys is unchanged — keeping that fix scoped to the routes that actually need cloud-token resolution prevents accidental scope creep into other routes that fence oncurrent_user is None. 4 new integration tests intest_api_key_cloud_access.py::TestSliceRouteCloudOwnerResolutionpin the dep contract: returns the owner for a key withcan_access_cloud=Trueand a valid owner; returns None for an owned key without the cloud scope (so cloud presets still 400 cleanly, local presets still slice); returns None for legacy ownerless keys; no-op for JWT and anonymous callers. -
Project cover photo thumbnail too small to recognise the print (#1155 follow-up, reported by @smandon) — The 40×40 thumbnail @smandon's MakerWorld download workflow relied on for "is this the model I'm looking for?" wasn't readable at that size; he asked for either a larger thumbnail or a click-to-enlarge full preview. Enlarging the thumbnail itself would shift the card layout and cost the dense grid he chose to use for browsing many projects, so the fix keeps the 40×40 thumbnail and shows a portal-mounted 384×384 popover on hover. The popover renders the full image in
object-containso tall portrait MakerWorld photos aren't cropped to a square, haspointer-events-noneso it can't intercept hover and create a flicker loop, andz-[100]so it stacks above every sibling card in the grid. Why a portal: ProjectCard carriesoverflow-hidden(for its rounded-corner clipping and the color accent bar), so an in-tree popover gets clipped by the card the moment it extends past the card's bounds — exactly the cut-off behaviour @smandon reported on the second iteration. Rendering viacreatePortal(..., document.body)escapes every ancestor clipping context, andposition: fixedwith measurements fromgetBoundingClientRect()keeps the popover pinned next to the thumbnail regardless of where the card sits in the grid. Edge handling: if the thumbnail is near the viewport's right edge the popover flips to the LEFT side of the thumbnail; vertical position is clamped so the popover never overflows the window top or bottom. The thumbnail's ownonClickisstopPropagation'd so hovering the popover area never accidentally triggers the parent card's "open project" navigation. 2 new tests inProjectsPage.test.tsxpin the contract: hovering mounts the popover at document.body level (not nested in the card — a future refactor that drops the portal would re-introduce the clipping bug, and the test catches that); leaving unmounts it; the popover img points at the same cover-image URL as the small thumbnail withobject-contain; cards without a cover_image_filename never mount the portal-rendering component (so a hover doesn't flash an empty preview). -
Spool edit form lost the Extra Colours value on reopen, Dual Color rendered identically to Gradient, and the Sparkle / checkerboard visuals were too subtle (#1154 follow-up, reported by @maugsburger) — Four issues against the multi-colour swatch work that landed for #1154. (1) Extra Colours input didn't hydrate on edit reopen:
ColorSection's draft buffer was seeded once viauseState(formData.extra_colors), butSpoolFormModalopens before its ownuseEffectpopulatesformDatafrom the spool record — so by the time the saved value landed, the input's local state had already been initialised to''and never re-synced. The COLOR preview banner above the input rendered correctly (consumes formData directly), making it obvious the data WAS persisted; only the input was stuck blank, which the user then had to retype to save anything else. Fix: a ref-guardeduseEffectresyncsextraColorsDraftwhenformData.extra_colorschanges via an external update (e.g. modal opening with a spool); the ref is updated insidecommitExtraColorsso the user's own typing is round-tripped without the resync clobbering it. (2)Dual ColorandGradientproduced the same diagonal blend:buildColorLayerinfilamentSwatchHelpers.tsran the samelinear-gradient(135deg, ...)for both effect types, so a "Dual Color" spool was visually indistinguishable from a "Gradient" one. Real dual-colour spools have two distinct bars on the reel — that's the whole point of the variant. Fix: wheneffect_typeisdual-colorortri-color, build the colour layer aslinear-gradient(to right, c1 0% X%, c2 X% Y%, ...)with CSS double-position stops (so the colour change is a hard line rather than a blend region) and equal-width segments across the stops;gradientkeeps the original 135° smooth blend. The existingmulticolorconic-gradient path is untouched. (3) Sparkle effect was almost invisible on card-sized swatches: the original 4-dot pattern (each ~1px) read fine on the small inline swatch but disappeared on the 60-pixel-tall inventory card banners — exactly where the user actually identifies a spool. Bumped to 13 flecks in mixed sizes (1px / 1.5px / 2px) and varying opacity (0.65 → 1.0) to give a depth-of-field "metal flake" feeling, distinct from solid + multi-colour. (4) Checkerboard cell density scaled with the swatch: the previous helper putrepeating-conic-gradient(...)in thebackground-imageand the caller appliedbackground-size: cover, so the same 4-cell pattern was either tiny squares on a small swatch or four huge squares on a card-sized banner. MadebuildFilamentBackground()return{ backgroundImage, backgroundSize }with per-layer sizes — painted layers staycover, the checkerboard gets a fixed 12px tile so the cell density stays consistent regardless of element size and clearly reads as a transparency indicator rather than a multi-colour stripe. Updated the three existing call sites (InventoryPagegroup banner + spool card,ColorSectionpreview) to spread the returned style object directly. 8 new frontend tests cover the four fixes: hard-split contract for Dual/Tri Color (3 tests + 1 regression guard that Dual ≠ Gradient for the same stops); Sparkle prominence (≥ 10 distinct radial-gradient layers in the rendered background); checkerboard density (lastbackgroundSizelayer is a fixed pixel value, notcover); 4 hydration tests pinning the input restore path (fills when formData arrives via parent update, resyncs when the spool changes mid-form, doesn't clobber live user typing, clears when the new spool has no extra_colors). -
Pending review card and the resulting archive name disagreed;
.gcode.3mffilename suffix wasn't fully stripped (#1152 follow-up, reported by @smandon) — Two distinct holes in the original #1152 fix surfaced when @smandon retested on the daily build. (1) Suffix stripping was incomplete: Bambu Studio's "Send to printer" dialog typically writes files likePlate_1.gcode.3mf(a sliced gcode payload wrapped in a 3MF container), but the archive's display stem was computed viaPath(name).stem, which only drops the last suffix and left the user staring atPlate_1.gcodein the archive UI. (2) The review card and the archive disagreed on what the print was called: the pending-uploads panel always rendered the raw FTP filename, while the eventualPrintArchive.print_nameresolved from the 3MF's embedded title (or, with the toggle onfilename, the filename stem). Net effect: the user sawPlate_1.gcodein the review card andSome Creator's Titlein the archive grid for the same item, with no toggle that flipped both views in lockstep. Fix has three pieces: a newresolve_display_stem()helper inarchive.pythat strips.gcode.3mf/.3mf/.gcode(case-insensitive) so both the archive and the review-side normalisation produce the same canonical stem; a newPendingUpload.metadata_print_namecolumn populated at FTP-receive time by peeking at the 3MF's embedded title (so/pending-uploads/list calls don't have to reopen every 3MF on every render); and a newPendingUploadResponse.display_namecomputed field that mirrorsarchive_print's exact precedence —filenametoggle: stripped stem;metadatatoggle (default): cached title or stripped stem. Frontend'sPendingUploadsPanelreadsupload.display_name(withupload.filenameas a defensive fallback for any pre-migration row), and the raw filename is exposed as a tooltip so users can still inspect what actually arrived over FTP. Migration is one idempotentALTER TABLE pending_uploads ADD COLUMN metadata_print_name VARCHAR(255)(Postgres/SQLite-safe); existing pending rows have NULL there and gracefully fall back to filename-stem behaviour. 14 unit tests pin the stripping rules (Plate_1.gcode.3mf→Plate_1, mixed case, dots in the middle, edge.3mf-only /.gcode-only, full-path inputs); 6 integration tests pin the response contract (default toggle uses metadata title when present, falls back to stripped stem when absent,filenametoggle overrides metadata,filenametoggle still strips the double suffix,GET /{id}exposes the same field, whitespace-only metadata behaves like absent); 3 frontend tests pin the review card's render path (resolved name shown, fallback to filename when display_name is empty, raw filename available via tooltip). -
SpoolBuddy SSH update fails with "permission denied for user spoolbuddy" after Bambuddy keypair rotation (reported during user testing) — Bambuddy's data dir at
<DATA_DIR>/spoolbuddy/ssh/can get recreated outside the daemon's control (volume remount, container recreate, fresh deploy), at which pointget_or_create_keypair()generates a new ed25519 keypair. The SpoolBuddy daemon previously only fetched and deployed Bambuddy's public key at registration time (/devices/register), so any rotation after a successful registration left the device's~/.ssh/authorized_keyspointing at a defunct public half — every "Update" click from the Bambuddy UI then failed withConnection closed by authenticating user spoolbuddy [preauth]until the daemon was restarted manually. Worse, every prior successful registration appended a fresh entry toauthorized_keyswithout ever pruning the old one, so a typical device accumulated 5+ stale Bambuddy-tagged keys (each one a permanent backdoor for whichever Bambuddy keypair held the matching private half at the time it was deployed). Two-pronged fix: (1) the heartbeat response (HeartbeatResponse,routes/spoolbuddy.py:282) now carries the currentssh_public_keyalongside the existingpending_command/ calibration fields, so the daemon's heartbeat picks up a key rotation within one cycle instead of needing a service restart; the sametry/except Exception: passpattern as the registration response keeps a missing/unreadable backend key from breaking telemetry. (2)_deploy_ssh_key()indaemon/main.pynow syncs rather than appends — it strips every line taggedbambuddy-spoolbuddy, writes the current key once, and is a no-op when already in sync (so it doesn't churn the file every heartbeat). User-managed entries (any line not taggedbambuddy-spoolbuddy) are preserved untouched. 5 new unit tests inspoolbuddy/tests/test_deploy_ssh_key.py(creates-when-missing → mode-600 file with the current key; pile-up-of-stale-keys → only current key remains, no growth; preserves-unrelated-user-keys → user's own SSH access untouched; idempotent-when-in-sync → no mtime change so heartbeat doesn't churn the file; swallows-write-errors → readonly-fs PermissionError doesn't crash the heartbeat loop). 2 new backend integration tests intest_spoolbuddy.py::TestDeviceEndpoints—test_heartbeat_returns_ssh_public_key(response carries the key on every heartbeat) andtest_heartbeat_ssh_key_failure_does_not_break_heartbeat(backend key-read failure leavesssh_public_key: Nonebut the heartbeat still 200s). -
External-camera frames returned as black on go2rtc and other MJPEG sources (#1177, reported by @nkm8) —
_capture_mjpeg_framereturned the very first JPEG it found in the stream's bytes (backend/app/services/external_camera.py:282), but many MJPEG sources — go2rtc most notably, and several IP cameras — emit a "warm-up" frame on the byte that follows connection accept: usually the last keyframe held in the encoder, which is often black or stale until the encoder catches up to live content. Subsequent frames on the same connection are fine. The reporter saw it across snapshot UX, finish photos in notifications, and timelapse — every code path that opens a fresh capture connection (snapshot endpoint,[PHOTO-BG]finish photo, plate-detection CV, Obico ML inference, layer timelapse, Settings → Test). His own observation that go2rtc's/api/frame.jpeg(single-frame, internally already warmed) is never black while the first frame off/api/stream.mjpegis, matched the hypothesis exactly. Support-bundle evidence was clean: every black notification frame in his log was 11095 bytes (a pure-black 1280×720 JPEG encodes to ~10–15 KB on standard libjpeg quality settings), while every captured-after-warm-up frame from the same source was 30–45 KB. Fix: read past the first frame and return the second; if the connection closes / times out / hits the 5 MB buffer cap before a second frame ever arrives, fall back to the first so callers still get something (degrading slow / single-frame streams to None would regress every code path that relied on pre-fix behaviour). The inner-loop now drains every complete frame already in the buffer before pulling the next chunk so high-FPS sources that pack multiple frames per chunk are handled correctly. Thesnapshot/rtsp/usbcapture paths and the live-view streaming endpoint (generate_mjpeg_stream) are untouched. 7 new regression tests intest_external_camera.py::TestCaptureMjpegFrameWarmupSkipcover (a) two-frames-in-two-chunks → second returned, (b) two-frames-in-one-chunk → second returned, (c) frame split across chunk boundary → assembled correctly, (d) single-frame stream → first returned via fallback (no None regression), (e) timeout after first frame → first returned via fallback, (f) zero-frame stream → None, (g) non-200 status → None. Latency penalty: at most one frame interval (typically 50 ms – 1 s on a steady stream). Follow-up: optional snapshot URL override — @nkm8 retested on the daily build and saw the warm-up skip help most of the time but the black-frame symptom still surfaced intermittently on his go2rtc setup, with the same workflow break (notification thumbnails black, snapshot UX black). His own bisect already pointed at the cleanest fix: go2rtc exposes/api/frame.jpegas a dedicated single-frame endpoint that never returns the encoder's warm-up keyframe, while/api/stream.mjpegalways does on a fresh connection. New optionalexternal_camera_snapshot_urlcolumn onprinters(idempotentALTER TABLEmigration via_safe_execute, plumbed throughPrinterBase/PrinterUpdate/PrinterResponse/from_orm_with_roi/ TypeScriptPrinter+PrinterCreate); when set, every single-frame capture path (/api/v1/printers/{id}/camera/snapshot,[SNAPSHOT]notification thumbnails,[PHOTO-BG]finish photo, layer timelapse on every captured layer, Obico ML snapshot, plate-detect / calibrate-plate CV) routes through_capture_snapshot()on the override URL via plain HTTP GET, bypassing the warm-up-frame dance entirely. The override is camera-type-agnostic — set it once on the printer config and it applies regardless of whether the live stream is mjpeg / rtsp / usb. Live-view (the/camera/streamand/cameraendpoints powering the in-app viewer) deliberately stays on the configured stream URL — the override only changes single-frame captures, since a 1 fps poll-the-snapshot-endpoint live view would be a regression for everyone who doesn't have this problem. Settings UI (Settings → General → External Cameras) renders a new "Snapshot URL (optional)" input with its own Test button below the live-stream URL row; the input is hidden whencamera_type === 'snapshot'since the live URL is already a single-frame endpoint and the override would be redundant. SSRF guard on the override is the existing_sanitize_camera_url("http", "https")allowlist — link-local / metadata / blocked hosts return None instead of being fetched. Empty-string override is treated as unset (defence in depth — a stale config row that somehow has""rather thanNULLstill routes through the live stream rather than firing GET against an empty URL). 5 new backend tests intest_external_camera.py::TestSnapshotUrlOverride(override routes to snapshot path; no override → camera-type handler; empty string → camera-type handler; SSRF guard on metadata-target override returns None; override is camera-type-agnostic across rtsp/usb). 3 new frontend tests inSettingsPage.test.tsx(input renders for mjpeg/rtsp/usb camera types; hidden for snapshot type; debounced PATCH carriesexternal_camera_snapshot_urlwhen the user types). i18n:settings.cameraSnapshotUrl{,Placeholder,Help}in en + de fully translated, the other 6 locales (fr/it/ja/pt-BR/zh-CN/zh-TW) seeded with English copies pending native translation. Documented underbambuddy-wiki/docs/features/camera.mdwith the go2rtc example URL as a tip block. -
MakerWorld sidebar entry visible to every user regardless of group permissions (#1175) — Backend already enforced
makerworld:viewon every/makerworld/*route (backend/app/api/routes/makerworld.py:145, 157, 242, 406), the permission was correctly granted to the admin and standard-user role defaults (permissions.py:298, 364, 454), and the frontendPermissiontype union already included'makerworld:view' | 'makerworld:import'(client.ts:2498) — but the sidebar's hand-maintainednavPermissionsmap inLayout.tsx:278had no entry formakerworld, soisHidden('makerworld')always returned false and the entry rendered for every authenticated user. Users without the permission saw the entry, clicked, and the page rendered while every API call inside it 403'd. Two-line fix: (1)Layout.tsx:278— addmakerworld: 'makerworld:view'to the map, matching every other sidebar entry's gating shape; (2)App.tsx:200— wrap the route in<PermissionRoute permission="makerworld:view">for defence in depth, so a user who knows the URL can no longer reach the page directly (matches the existing pattern onsettings,groups/new,groups/:id/edittwo lines below). 2 new Layout tests pin the contract: with auth enabled and a user lackingmakerworld:view, the sidebar<a href="/makerworld">link is absent (other links like/filesstill render); with the permission granted, the link renders. -
Printer Info modal: serial-number and IP-address copy buttons silently did nothing on plain-HTTP LAN deployments (#1174, reported by @BurntOutHylian) —
PrinterInfoModal'sCopyButtononly triednavigator.clipboard.writeText(), which is gated by the secure-context requirement (HTTPS or localhost). On the typical Bambuddy deployment shape — bare-IP HTTP on the LAN —navigator.clipboardis undefined; the existingtry/catchswallowed the resultingTypeError, the icon never flipped to the tick, and nothing landed on the user's clipboard. Fixed by adding the same off-screen-textarea +document.execCommand('copy')fallback thatCameraTokensPage's plaintext-token modal already uses for plain-HTTP LAN deployments: gate onnavigator.clipboard && window.isSecureContext, fall back to the legacy path otherwise, and surface the success-tick only when the copy actually landed (return early without flippingcopiedifexecCommand('copy')returns false). Thetry/finallyaround the textarea guarantees DOM cleanup even when the browser throws on a restricted context. 3 new component tests inPrinterInfoModal.test.tsxcover (a) secure-context happy path usesnavigator.clipboard.writeText, (b) plain-HTTP fallback path actually invokesexecCommand('copy')and leaves no leaked textarea in the DOM, (c)finallycleanup removes the textarea even whenexecCommandthrows synthetically. Thanks to @BurntOutHylian for the precise file/line pointer in the report. -
Queue auto-dispatched the next print onto a fouled bed after an aborted or cancelled print (#1171, reported by @tom5677) — When a print ended with status
aborted(printer self-abort, or a user stopping the print on the printer's own touchscreen) orcancelled(user stopping the print via the Bambuddy queue UI), the plate-clear gate added in #961 was not raised — onlycompletedandfailedtriggered it (backend/app/main.py:2660). Result: the queue scheduler dispatched the next pending item ~2 seconds after the abort, with the previous print's material still on the bed. The reporter saw two prints (P1P + P1S) auto-start onto fouled beds within seconds of each other after touchscreen-aborts, and explicitly flagged the risk of damage to the printer; a third printer (his second P1S) behaved correctly because its previous print had endedcompleted. The original code's comment ("user-cancelled prints don't require a plate-clear ack — nothing printed on the bed") only holds if you cancel right at layer 1; cancelling a 12-hour print at hour 11 leaves a fouled bed too. Fix: the gate is now raised for every terminal status —completed,failed,aborted,cancelled— matching the safety contract that the user must acknowledge the bed is clear before any next queued print starts. The gate is user-clearable on the Printers page, so worst case for a layer-1 cancel the user clicks "Clear Plate" once. Touchscreen-aborts are particularly important to gate because Bambuddy's "user stopped via UI" override (_user_stopped_printers→abortedmapped tocancelled) only fires when the user stops via the Bambuddy queue; a touchscreen-stop reportsabortedstraight through. Regression coverage intest_print_lifecycle.py::TestPlateClearGate: parametrised across all four terminal statuses (assertsset_awaiting_plate_clear(printer_id, True)is called for each), plus a defence-in-depth test that an unrecognised future status string never silently raises the gate. -
Printer card always shows the first plate's thumbnail when printing a multi-plate 3MF (#1166, reported by @smandon) — On printers running firmware that drops the plate path from
print.gcode_file(the reporter's case: P1S 01.10.00.00, but the same shape appears on other firmware revisions), the printer reportsgcode_file: MyModel.3mfinstead ofgcode_file: /Metadata/plate_4.gcode. The/printers/{id}/coverroute's regex (plate_(\d+)\.gcode) found nothing in the bare.3mffilename, defaulted to plate 1, and the printer card showedMetadata/plate_1.pngfrom the 3MF — even though the user dispatched plate 4. Same problem hitcurrent_plate_idon the status response (printer card detail row showed plate 1). Two-pronged fix on a precedence ladder: (1) Bambuddy now records the plate it dispatched —start_print()writes(dispatched_plate_id, dispatched_subtask)ontoPrinterStateat publish time, and a newresolve_plate_id(state)helper prefers that record over the gcode_file regex whendispatched_subtask == state.subtask_name(the subtask check rejects stale entries from a prior Bambuddy-dispatched print bleeding into a Studio-direct dispatch). (2) After the 3MF lands on disk, the cover route scans the zip for a uniqueMetadata/plate_*.gcodeentry: per-plate archives sliced separately in Bambu Studio bundle thumbnails for every plate but only the active plate's gcode, so a single match unambiguously identifies the plate even when no Bambuddy dispatch exists (Studio-direct flow). Final fallback is plate 1, unchanged. The cover-byte cache key was also simplified —plate_numwas removed from the key now that resolution is late-bound;clear_cover_cache()already runs on every print start, so different plates of the same project always re-fetch a fresh thumbnail. Coverage: 5 unit tests intest_printer_manager.py::TestResolvePlateId(dispatch precedence, stale-subtask guard, gcode regex fallback, default-1 path, missing-subtask guard), 4 unit tests intest_bambu_mqtt.py::TestStartPrintRecordsDispatchedPlate(dispatch record set/cleared/overwritten/skipped on disconnect), 2 integration tests intest_printers_api.py(dispatch wins over plate-1 default; 3MF-scan fallback for per-plate archive without dispatch). Studio-direct multi-plate prints (no dispatch record AND multiple plate gcodes in the 3MF) still default to plate 1 — matches the firmware's own ambiguity, not regressed by this change. -
AMS slot configuration intermittently fails to reach the printer after several configs in a row (#1164, reported by @RosdasHH) — Configuring AMS slots a handful of times (the reporter saw it almost every 6th change) would silently stop reaching the printer; ~1 minute later the filament colours on the printer would briefly jump between slots, then settle. Root cause was the zombie-session watchdog at
bambu_mqtt.py:861introduced for #887. When anams_filament_settingresponse took >10 s (normal under load — concurrent K-profile fetches, busy printer, network jitter) the watchdog incremented an_ams_cmd_unansweredcounter and zeroed_last_ams_cmd_timeso it wouldn't re-trigger on the next status push. The bug: the response handler that reset the counter was guarded byand self._last_ams_cmd_time > 0— so when the late response did arrive (after the watchdog had already zeroed the timer), the counter stayed armed at 1. The next slow response on anyams_filament_settingcommand — possibly minutes or hours later, on an entirely unrelated config attempt — would take the counter to 2 and triggerforce_reconnect_stale_session(). The user-visible symptoms match exactly: configs stop landing (because MQTT reconnects mid-publish, dropping the in-flight command and surfacing asCannot set AMS filament setting: not connectedif the user retries during the ~1 min reconnect window), then the queued state finally lands when the reconnect completes (the "filament colours jumping around" the reporter described). Fix is to drop the_last_ams_cmd_time > 0guard: anyams_filament_settingresponse — late or not — proves the channel is alive, so the counter must reset. Watchdog still trips on a real zombie session (no responses at all for two consecutive >10 s windows). Regression test intest_bambu_mqtt.py::TestZombieSessionDetection::test_late_response_after_watchdog_clears_counter_issue_1164simulates the exact sequence (watchdog fires → late response arrives → second slow response on a fresh command) and asserts the counter resets to 0 on the late response and the second command doesn't tip the threshold to 2. Other 10 zombie-detection tests still pass unchanged. Follow-up: cumulative session wedge after ~16-20 commands — the watchdog fix above heals real zombie sessions, but @RosdasHH continued to see the wedge fire on healthy sessions after enough cumulative commands (configs + spool assignments share the same threshold: "8 + 3", "12 + 1", "16 + 0" all tripped it). His QoS=1 vs QoS=0 vs QoS=2 bisect was the breakthrough — the wedge only happens at QoS=1. paho-mqtt's defaultmax_inflight_messagesis 20, and Bambu's broker has racy PUBACK matching that leaves some inflight slots unreleased per session, so after ~16-20 cumulative commands the queue silently fills andpublish()returns success while packets sit in paho's internal queue (force_reconnect heals it because the inflight queue is per-session — the printer had already processed every command, it just couldn't receive any new ones until the session reset). Lifted the ceiling to 1000 viaclient.max_inflight_messages_set(1000)immediately aftermqtt.Client()construction (bambu_mqtt.py:3074-3079). Keeps QoS=1 untouched (the cross-model reliability we deliberately chose for AMS configuration — A1, P1S, X1C, H2D, P2S, X2D all need it) and removes the ceiling as the bottleneck without changing wire-protocol behaviour. The watchdog reconnect from the original fix above stays as defence-in-depth for sessions that go truly zombie. Diagnosis credit: @RosdasHH's careful bisect.
[0.2.4b1] - 2026-04-29
Added
-
Enhanced filament colour handling: multi-colour gradients, transparency, visual effects (#1154) — A solid hex swatch is the wrong abstraction for a tri-colour, gradient, or sparkle filament — the colour you saw on the spool inventory page was just whatever Bambu's firmware reported as the dominant tone, and there was no way to record what the spool actually looked like. The Spool form's "Colour" section now accepts a paste of up to 8 comma-separated hex stops (
EC984C,#6CD4BC,A66EB9,D87694— exact format from 3dfilamentprofiles.com) and renders them as a CSS gradient on every swatch site (inventory grid, table, group banner, card, ColorSection preview, color-catalog admin). A new Effect dropdown — covering surface effects (Sparkle / Wood / Marble / Glow / Matte), sheen variants (Silk / Galaxy / Rainbow / Metal / Translucent), and structural variants (Gradient / Dual Color / Tri Color / Multicolor) — layers a CSS overlay on top of the colour layer (or, for Multicolor, switches the colour layer to a conic-gradient even when no spool subtype is set, so the catalog editor can flag a multicolor variant directly without needing a paired Spool row). Independent ofsubtype— so the user can override the visual hint without touching Bambu's categorical filament label or the MQTT auto-detection chain. Transparency is now actually visible: the existingrgbacolumn has always stored an alpha byte but every render site flattened it withsubstring(0, 6); the new shared<FilamentSwatch>component renders against a checkerboard layer beneath the colour layer so any alpha < 0xFF shows through (matches the convention used by image editors and 3dfilamentprofiles.com). Multicolor subtype swaps the linear gradient for aconic-gradientso the swatch reads as a colour wheel pie instead of a stripe — visually distinguishes a true multi-colour spool from a 2-stop gradient. Colour-catalog parity: the same fields land onColorCatalogEntry(Settings → Color Catalog) so a user can save a multi-colour combo once and pick it from the catalog palette across spools — added inline to both the Add form and the inline-edit row, threaded through the JSON export/import path so catalog backups round-trip the new fields. Cataloghex_colorregex extended to optionally accept#RRGGBBAAfor transparency-aware catalog entries (backward-compatible — existing 6-char rows still validate). Schema validation (backend/app/schemas/spool.py::normalize_extra_colors+normalize_effect_type— public soColorEntryCreate/ColorEntryUpdatecan reuse them): comma-separated hex with 6-or-8-char tokens, lowercase canonical form,#prefix stripped, max-8-stop cap, empty tokens dropped (so a degenerate paste like,,FF0000,survives), invalid tokens rejected at the Pydantic layer with a precise field error. Effect type validated against the fixed set{sparkle, wood, marble, glow, matte, silk, galaxy, rainbow, metal, translucent, gradient, dual-color, tri-color, multicolor}— paste-friendly normaliser toleratesDual Color/dual_color/dual-colorand canonicalises todual-color. Both validators live next toSpoolBaseand are reused byColorEntryCreate/ColorEntryUpdateso spool-side and catalog-side rejection rules can never drift. Frontend swatch component is one shared<FilamentSwatch>(andbuildFilamentBackground()helper for callers that want just the CSS background-image string for a banner) — used by InventoryPage table, group banner, SpoolCard, ColorSection preview, and ColorCatalogSettings — so there's exactly one place that decides how a filament looks. Colour layer is built as a list of CSS images (nobackground:shorthand) so jsdom and every browser parse it consistently; checkerboard layer is the one that makes alpha visible. Migrations are 4 idempotentALTER TABLE ... ADD COLUMN(Postgres-safe, noDEFAULT 0traps) plus a Postgres-only widen ofcolor_catalog.hex_colortoVARCHAR(9). i18n: 12 new keys underinventory.*across all 8 locales (en/de/zh-CN/zh-TW fully translated; fr/it/ja/pt-BR seeded with English copies pending native translation, matching the project's flow for newly-added user-facing features). 42 new backend tests (35 unit + 7 integration) covering the normalizer (paste-from-3dfilamentprofiles canonicalisation, whitespace tolerance, mixed 6/8-char, empty-token drop, max-stop cap, invalid-hex rejection, wrong-length rejection,Dual Color/dual_color→dual-colorcanonicalisation), effect-type validator across all 14 allowed values, end-to-end POST/PUT/PATCH round-trip on both spool + catalog routes, 8-charhex_coloracceptance, dedupe-on-update, and field clearing via empty string vs explicit null. 20 new frontend tests covering FilamentSwatch (14 — solid render, multi-stop linear gradient, conic for Multicolor subtype AND formulticoloreffect_type via the catalog path, surface-effect overlays for sparkle and silk, categorical-only-no-overlay for gradient/dual-color, unknown-effect-ignored, checkerboard rendering for alpha, invalid-hex skip in stops, title fallback),buildFilamentBackgroundhelper, ColorCatalogSettings (3 — Add form sends extra_colors + effect_type, full 14-value dropdown, inline edit hydrates from existing entry), and InventoryPage spool grouping (3 — different extra_colors don't collapse, different effect_type don't collapse, identical multi-colour spools still group). Out of scope for V1: gradient stop positions (e.g. 25%/75%), MQTT-side auto-import of multi-colour from Bambu (firmware doesn't expose this), per-effect tunable parameters — the current shape closes the user's actual paste-from-3dfilamentprofiles workflow without taking on a structured-stop-position editor. -
Project URL + cover photo (#1155) — Two new fields on every project: a free-text URL (rendered as a 24×24 bordered green button beside the project name on every card; opens in a new tab and click is
e.stopPropagation()-guarded so it doesn't enter the project) and a cover photo (replaces the status-icon box on the card with a square thumbnail). The URL field is plumbed throughProjectCreate/ProjectUpdate/ProjectResponse/ProjectListResponse, including from-template + create-template flows so the URL inherits between a project and its template; cover photo is not inherited because the file would be shared on disk between the source and copy. Schema validator rejects anything other thanhttp://orhttps://prefixes —<a href>rendering would otherwise executejavascript:/data:/file:URLs even with React's default escaping. Cover image storage:Project.cover_image_filenamereferences a file inside the existingarchives/projects/{id}/attachments/directory, but it's tracked as a separate column from theattachmentsJSON list so swap/delete operations on the cover don't perturb the user's other attachments. Three new routes (POST /projects/{id}/cover-image,GET /projects/{id}/cover-image,DELETE /projects/{id}/cover-image) — accepts only.jpg/.jpeg/.png/.gif/.webp(no SVG: SVG can carry script payloads), replaces in place (the prior file is removed from disk before the new one lands so repeat uploads can't accumulate orphans), and self-heals when a DB reference points at a vanished disk file by clearing the column and 404'ing rather than repeatedly touching the filesystem. GET auth gate: the cover-image GET route is gated byRequireCameraStreamTokenIfAuthEnabled(accepts the same?token=…stream credential the archive thumbnail route uses) rather than the bearer-token gate —<img src>requests can't carry anAuthorizationheader, and the bearer gate would silently 401 every cover image when auth is enabled. The frontend client wraps the URL withwithStreamToken(...)so the modal preview AND the card thumbnail load in both auth-on and auth-off configurations. PATCH update usesmodel_fields_setfor the URL field so users can clear it by sending{"url": null}. Permissions:PROJECTS_UPDATEfor upload/delete/PATCH,PROJECTS_READfor the GET (via the stream-token gate). Migration: 2 idempotentALTER TABLE projects ADD COLUMN ...statements. Localised across all 8 UI languages (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW) — English fully translated, the seven other locales seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features. 7 backend integration tests covering URL accept/reject (https/javascript/data), URL clear, cover image upload→serve→delete round-trip with content-type assertion, non-image rejection, and a regression guard verifying the GET route is wired to the stream-token gate (not the bearer gate). 4 frontend ProjectsPage tests covering the link icon render condition, click-propagation guard, no-link-when-unset, and cover-image thumbnail render; 3 frontend client tests pinning thatgetProjectCoverImageUrlappends the stream token, returns the bare URL when no token is set, and URL-encodes tokens with query-string-unsafe characters. Cover image upload is only available on the edit modal (an existing project), since the upload needs a project_id; new projects can add it after first save. -
"Not Printed" / "Printed" collections on the Archives page (#1153) — Virtual-printer uploads land in the archives view with
status='archived'(uploaded but never sent to a printer), but the existingCollectionsidebar only hadAll / Recent / This Week / This Month / Favorites / Failed / Duplicatesso there was no way to surface "what's still queued in my library that I haven't printed yet" vs "what already went to a printer." Two new collections fill that gap: Not Printed filters tostatus === 'archived'(the VP upload state); Printed filters to any final-status archive —completed,failed,aborted,cancelled,stopped— so a user can see every archive that had a print attempt regardless of outcome (the existing "Failed" collection covers just the failure subset). Frontend-only — the data has always been there, just no UI handle for it. 2 new tests inArchivesPage.test.tsx::Not Printed / Printed collectionspin the filter behaviour against a fixture covering all 4 status states (archived / completed / failed / cancelled). -
Virtual-printer archive name source toggle (#1152) — Slicer-uploaded archives picked up their display name from the 3MF's embedded
print_namemetadata, which is whatever the original creator set; users who renamed a job in BambuStudio's "Send to printer" dialog never saw that name surface in Bambuddy because the FTP-uploaded filename was only ever used as a fallback when the metadata was empty. Settings → Virtual Printer now exposes an Archive name source toggle (Metadata / Filename, default Metadata, preserves existing behaviour) at the top of the page that flips precedence inArchiveService.archive_printfor every VP-sourced archive —_archive_file,_add_to_print_queue,POST /pending-uploads/archive-all, andPOST /pending-uploads/{id}/archiveall read the newvirtual_printer_archive_name_sourcesetting and forwardprefer_filename_for_nameaccordingly. Backend validates the value tometadata/filenameonly. Strict locales (en/de/zh-CN/zh-TW) get full translations; 4 unit tests parametrised overfilename/metadata/ unset / empty-string pin the precedence rule end-to-end through_archive_file. Existing post-archivePATCH /archives/{id}rename path is unchanged. -
Multi-color slicing in the Slice modal, with per-plate filament discovery for unsliced project files — Initial slice support assumed a single filament profile per slice; multi-color 3MFs were silently truncated to the first slot, producing wrong colours on every non-trivial print. The Slice modal now (1) opens a plate-picker step first when the source is a multi-plate 3MF, (2) renders one filament dropdown per AMS slot the picked plate actually uses, with each dropdown auto-populated against the user's local + standard presets by
(filament_type, filament_colour)match, and (3) submits the user's picks as an orderedfilament_presets: PresetRef[]array which is forwarded as repeatedfilamentProfilemultipart parts to the slicer sidecar (the CLI joins them with;for--load-filaments). Per-plate filament list source-of-truth chain: for a sliced archive the modal readsMetadata/slice_info.configdirectly (existing path); for an unsliced project file (whereslice_info.configis empty until Bambu Studio actually slices), the newslice_previewservice runs a fast preview-slice via the sidecar'sslice_without_profiles(the project's embedded settings drive the slice; we throw away the gcode and only parse the resulting slice_info), and the result is cached by(kind, source_id, plate_id, content_hash)with LRU eviction at 256 entries — repeat opens of the same plate are instant. If the sidecar isn't reachable the modal falls back to a heuristic that readsMetadata/project_settings.configfor the AMS slot config and intersects it with the plate's painted-face data (paint_colorquadtree leaves on per-object .model files, scanned with a 5% noise threshold to drop single-leaf edit accidents). SliceModal-only tier priority is nowlocal → cloud → standard(wascloud → local → standard): imported profiles win because they carry parsed type/colour metadata in the response, while cloud entries don't (the per-preset detail endpoint rate-limits at ~10/sec per token and 50+ parallel fetches returned 429 on every request). The unified-listing endpoint's dedup pass now backfills metadata cross-tier — if a cloud entry wins dedup over a same-named local entry, the cloud entry inherits the local'sfilament_type/filament_colourso the Slice modal's metadata-aware pre-pick keeps working for users who have presets both cloud-synced and locally imported. Other consumers of/slicer/presets(Profiles page, etc.) retain the existing cloud-first dedup. Sidecar (orca-slicer-api fork,bambuddy/profile-resolverbranch):/slicenow accepts up to 16 repeatedfilamentProfileparts (was hard-capped at 1), the slicing service materializes each asfilament_N.jsonand joins paths into a single--load-filaments "a.json;b.json;c.json"invocation;/profiles/bundledlisting was extended withfilament_typeandfilament_colourper leaf so the bundled tier carries metadata into the modal. Sliced-archive card now reflects the actually-used filament list, not the project-wide AMS config:slice_and_persist_as_archivepreviously copiedfilament_typeandfilament_colorfrom the unsliced source archive verbatim, which inherited every project-wide AMS slot (16+ swatches on the card for a 2-color print). The new archive now reads those fields from the sliced output'sslice_info.configviaThreeMFParser(which already gates onused_g > 0), falling back to the source archive's values only if parsing failed. Backwards compatibility:SliceRequestschema accepts three shapes — legacyfilament_preset_id: int, source-aware singularfilament_preset: PresetRef, multi-color arrayfilament_presets: list[PresetRef]— the validator promotes any of them into a populatedfilament_presetslist before the route handler runs, and stale browser tabs from before this change keep working unchanged. Permissions: no new endpoint paths added; the preview-slice runs inside/filament-requirements(gated onLIBRARY_READ/ARCHIVES_READ) and the multi-filament dispatch runs insidePOST /slice(gated onLIBRARY_UPLOAD) — no auth surface widened. Tests: 6 schema tests forSliceRequestcovering the multi-filament list shape and legacy-vs-new precedence; 9 unit tests forslice_previewcovering happy path, content-hash invalidation, sidecar-failure no-cache-poison, concurrent-call thundering-herd guard via per-keyasyncio.Lock, and LRU eviction-with-lock-cleanup; 15 unit tests forextract_project_filaments_from_3mf(5 cases) andextract_plate_extruder_set_from_3mf(10 cases including the 60/40 painted-threshold pin); a multi-filament wire-format test onslice_with_profilespinning that N filament profiles produce N repeated multipart parts in submission order; 22 frontend SliceModal tests covering the plate picker step, multi-color rendering, metadata-aware pre-pick, manual slot override, archive-vs-library routing, and the new tier order. Localised across all 8 UI languages (English + German fully translated, the six others seeded with English copies pending native translation per the project's existing flow). -
Slicer presets now span Cloud, imported, and slicer-bundled tiers, end-to-end — Initial slicer integration only saw DB-backed local imports, so a user without imported profiles got an empty Slice modal even when their Bambu Cloud account or the slicer sidecar carried perfectly usable presets. The Slice modal now pulls from three tiers in priority order — cloud (the user's own Bambu Cloud presets), local (DB-backed imports), standard (slicer-bundled stock profiles) — with name-based dedup so a preset that exists in multiple tiers only renders in the highest-priority one (cloud > local > standard) and within-tier order is preserved exactly. Listing (
GET /api/v1/slicer/presets): cloud branch is per-user with a 5-minute cache keyed on(user_id, sha256(token)[:16])so a logout/login or token rotation auto-invalidates without callback wiring from the cloud-auth routes. Bundled branch is global with a 1-hour cache (sidecar's read-only filesystem only changes across image rebuilds).cloud_status(ok/not_authenticated/expired/unreachable) drives a precise modal banner instead of an unexplained empty list. Slicing (POST /library/files/{id}/slice,POST /archives/{id}/slice): request body now accepts source-aware{source, id}triplets per slot (cloud / local / standard) alongside the legacy*_preset_idfields for full backwards-compatibility — the schema validator normalises bare integer ids intoPresetRef(source='local', id=str(int))so the dispatcher only deals with one shape. Newpreset_resolverservice fetches the preset content per source: cloud viaBambuCloudService.get_setting_detail(unwraps thesettingenvelope, falls back to top-level on minor shape variants), local from the DB (existing path), standard via a minimal{inherits: <name>, from: "system"}stub that the sidecar'sbambuddy/profile-resolverbranch flattens againstBUNDLED_PROFILES_PATH/<category>/<name>.json— no preset-content round-trip needed for the standard tier. Permissions: the listing route gate matches the slice action itself (LIBRARY_UPLOAD) so any user who can slice can populate the dropdowns; the cloud branch has an independentCLOUD_AUTHcheck inside the fetch helper — a user holdingLIBRARY_UPLOADbut notCLOUD_AUTHdoesn't see the cloud tier (and can't slice with a cloud preset, returns 403) even if a leftoverUser.cloud_tokensurvived a permission revocation. SliceModal (frontend): grouped<optgroup>per tier with localised section headers, default-selection follows the cloud > local > standard priority on first load, cloud-status banner with three variants (sign-in / expired / unreachable) only when the status isn'tok. Sidecar (orca-slicer-api fork,bambuddy/profile-resolverbranch): newGET /profiles/bundledwalksBUNDLED_PROFILES_PATH/{machine,process,filament}and returns instantiable presets only (instantiation: "true"), filtering out abstract bases likefdm_filament_plaso the dropdowns only offer things a user can actually pick. Tests: 17 unit tests for the listing endpoint helpers (dedup priority + per-slot scoping + order preservation, all fourcloud_statusstates,CLOUD_AUTHdefence-in-depth with token lookup short-circuit, per-user cache isolation, token-change cache invalidation, sidecar-unreachable fallback), 11 unit tests for the source-aware resolver (standard inherits-stub shape, local DB lookup withpreset_typevalidation, cloud envelope unwrapping with both standard and top-level shapes, cloud auth-error → 401, cloudCLOUD_AUTHdefence, slot dispatch routing), 6 schema tests forSliceRequestcovering legacy bare-int normalisation and new source-aware refs and explicit-ref-wins-over-legacy precedence, 12 frontend tests for SliceModal covering tier-priority auto-selection,<optgroup>grouping, fallback when higher tiers are empty, source-aware payload on submit, manual override across tiers, archive-vs-library routing, error display, and all three banner variants. All 3391 backend + 1531 frontend tests pass. -
Server-side slicing via OrcaSlicer / Bambu Studio sidecar — Bambuddy can now slice models without a desktop slicer installed. New optional
slicer-api/Compose stack runs HTTP wrappers around the OrcaSlicer and/or Bambu Studio CLI; Bambuddy's File Manager and Archives pages get a Slice button that picks a printer / process / filament preset and dispatches a background slice job whose result lands as a new.gcode.3mfin the same library folder (or as a new archive when the source was an archive). Settings → Workflow gets a new Slicer card: pick the preferred slicer, toggle "Use Slicer API" on, and paste the sidecar URL — Slice buttons across File Manager, Archives, and MakerWorld then route through the API instead of the OS slicer URI scheme. Status updates come from a globalSliceJobTrackerProviderthat polls/api/v1/slice-jobs/{id}and surfaces a single toast per job (queued → running → completed / failed) plus auto-refreshes the file or archive list on success — slicing one file no longer pins the modal. Server side, a fresh in-memory dispatcher (backend/app/services/slice_dispatch.py) runs jobs asasyncio.create_tasks with a 30-minute retention sweep, and the routes (POST /library/files/{id}/slice,POST /archives/{id}/slice) return 202 immediately with{job_id, status, status_url}instead of holding the request open through a multi-minute slice. The CLI bridge (backend/app/services/slicer_api.py) distinguishes 4xx (SlicerInputError), 5xx (SlicerApiServerError), and connection failures (SlicerApiUnavailableError) so 3MF inputs can transparently retry with embedded settings when the sidecar's--load-settingspath segfaults on the input — empirically required for OrcaSlicer 2.3.x + H2D and signalled to the UI viaused_embedded_settings: true. Sliced output is forced to.gcode.3mfso File Manager picks up the embedded thumbnail, theprint_nameis dropped from saved metadata so the displayed filename matches what the user picked, andfile_type="gcode"paints the badge blue. The polling endpointGET /api/v1/slice-jobs/{id}is gated onLIBRARY_READsince job IDs are sequential and the body leaks source filenames + resulting library/archive IDs. The sidecar itself builds from a fork of AFKFelix/orca-slicer-api (maziggy/orca-slicer-api@bambuddy/profile-resolver) which adds theinherits:chain resolver,from: "User"→"system"rewrite,#clone-prefix strip, and sentinel-value strip empirically required to slice real OrcaSlicer GUI exports without segfaulting the CLI; the Compose file uses Docker's git-build-context so users don't clone it manually. Default ports are 3003 (orca) and 3001 (bambu-studio) — 3000/3002 are skipped because Bambuddy's virtual-printer feature owns them. 10 backend integration tests cover sync validation (404/400), happy-path enqueue, preset-error → failed job, sidecar unreachable, the 3MF embedded-settings fallback, STL no-fallback, and the strip-before-forward path; 5 new frontend tests for the SliceModal cover preset gating, library + archive enqueue paths, error display, and preset-load failure. New i18n keys underslicer.*andsettings.slicer.*across all 8 locales (English fully translated; the seven other locales seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features). Slicer integration is opt-in: if "Use Slicer API" stays off, the existing "open in desktop slicer via URI" flow is the default and unchanged. -
Per-spool category + low-stock threshold override (#729 — minimal version) — Two new fields on the spool form: a free-text Category (with autocomplete from categories already in use, so users naturally re-use "Production" instead of accidentally typing "production" / "prod") and a per-spool Low-stock threshold (%) override that defaults to the global setting if left blank. Powers the "I want to differentiate critical spools from prototype spools and alert at different thresholds" use case from the issue without taking on the full multi-tag taxonomy + auto-apply-rules + per-tag alert system the ticket originally proposed (which would have been ~5x the work for the same underlying value). Inventory page gains a Category filter chip — only renders once at least one spool carries a category, otherwise hidden so the chip row stays uncluttered. Low-stock counts in the stat-card and the "Low Stock" filter both honour the per-spool override (so a "Production" spool with override = 90% will count as low-stock at 80% remaining even when the global threshold is 20%). 50-char cap on category, 1-99% range on threshold (0 and 100 are both rejected as footguns). 9 new backend schema-validation tests covering the field defaults, partial-update behaviour, range/length rejection; 2 new frontend tests confirming the per-spool threshold pulls in spools the global threshold misses, and that the category filter chip stays hidden until at least one spool has a category. Localised across all 8 UI languages with full translations. The full multi-tag taxonomy from the original issue isn't going forward; if demand for it grows past the current 3 thumbs-up the design can layer on top of these fields without breakage.
-
Per-event ntfy priority (#990) — ntfy supports a
Priorityheader (1=min, 2=low, 3=default, 4=high, 5=urgent) that drives sound, visibility, and push behaviour on the receiving device, but the existing notifier sent every event at the server default — so a "50% complete" ping looked identical to "print failed" or "printer offline". The Add/Edit Notification modal now renders a per-event "ntfy Priority" section (visible only when the provider type isntfy) listing each enabled event with its own Min / Low / Default / High / Urgent dropdown; selections persist into the provider'sconfig.event_prioritiesmap and the backend emits a matchingPriority: Nheader on the ntfy POST/PUT request (including the image-attachment path). Events not explicitly mapped, malformed values, and out-of-range values (0, 6, "abc", null) all fall through to ntfy's server-side default — there is no clamping, so a misconfigured value never silently sends at the wrong urgency. Test sends (noevent_typecontext) deliberately omit the header so the test path cannot accidentally page someone at urgent priority. Existing providers withoutevent_prioritiesare untouched on upgrade. Localised across all 8 UI languages with full translations (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). 6 new backend tests covering header set on mapped event, omitted on unmapped event, omitted when noevent_prioritiesconfigured, omitted whenevent_typeis missing, ignored for out-of-range / non-numeric values, and propagated through the image-attachment PUT path. -
Long-lived camera-stream tokens for HA / Frigate / kiosks (#1108) — The existing
?token=…camera-stream tokens expire after 60 minutes which forced home-automation integrations (Home Assistant cards, Frigate, hallway kiosks) to either refresh on a cron or run with auth disabled. New self-service "Camera API Tokens" panel under Settings → API Keys (also reachable via the existing settings search box — type "camera token" / "frigate" / "home assistant") lets any user holdingcamera:viewmint a long-lived token they can paste once and forget. Revoke uses Bambuddy's standard styled confirmation modal (nowindow.confirmbrowser default — same pattern as the rest of the app). Tokens are scoped strictly to camera streaming (no privilege escalation surface — no other endpoint accepts them), formattedbblt_<8-char-prefix>_<32-char-secret>, and stored as a pbkdf2 hash so even a DB dump can't replay them; the plaintext is shown to the user exactly once in a copy-to-clipboard modal (with adocument.execCommand('copy')fallback for plain-HTTP LAN deployments wherenavigator.clipboardis gated by the secure-context requirement). Hard 365-day max — the issue'sexpire_in: 0(never) is explicitly rejected because an irrevocable infinite token is a footgun-by-design; UI defaults to 90 days, the cap is enforced both client-side (input clamp) and server-side (validation guard). Owners can revoke their own tokens; admins additionally see an "All users" view for leak triage and can revoke anyone's. The/camera/stream?token=…auth dependency tries the existing 60-min ephemeral row first (no behaviour change for the common browser case) and falls through to the long-lived path, so the SPA's existing camera flow is unaffected. Indexedlookup_prefixkeeps verify O(1) per token even on large installs — pbkdf2 only runs against the one candidate row that matches the prefix, never the whole table. Newlong_lived_tokenstable (separate fromauth_ephemeral_tokensbecause the lifecycle is different — user-owned, named, revocable, hashed; and separate fromapi_keysbecause that one is for global webhooks with no user FK and a different permission shape). 15 unit tests covering create-validation/scope/expiry rules, verify happy/garbage/expired/revoked/scope-mismatch/prefix-collision paths, list-by-user vs list-all, idempotent revoke; 14 integration tests covering the create-once-then-listing-hides-plaintext contract, the 365-day cap, the auth gate, owner-vs-admin revoke ownership rules, and that the long-lived token verifies through the same camera-stream auth dependency the route uses (and that revoke immediately invalidates it). 6 frontend tests covering list render, empty state, create-then-shown-once flow, days-input clamp, revoke-with-confirm, and revoke-cancelled paths. NewcameraTokens.*keys across all 8 locales (English fully translated; the seven other locales seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features). -
Tailscale integration for virtual printers (builds on #1070 by @legend813) — Opt-in per-VP Tailscale toggle brings each virtual printer into the tailnet, so it's reachable from any tailnet device over a private WireGuard tunnel without port forwarding or public exposure. When enabled, Bambuddy provisions a Let's Encrypt cert for the VP's MagicDNS hostname via
tailscale certand the MQTT/FTPS listeners serve it. Slicer-side caveat worth knowing up front: both Bambu Studio and OrcaSlicer only accept IP addresses (not hostnames) in the Add Printer dialog, so the LE cert's hostname validation doesn't apply — users still need the Bambuddy CA imported into the slicer, same as LAN mode. The practical benefit here is the private tunnel (remote access without DDNS / port forwarding / public exposure), not cert-import elimination. Default is opt-out (toggle off) so users without Tailscale don't see cert-provisioning attempts or log noise. When a user flips the toggle on a host without a working Tailscale binary, the backend returns409 tailscale_not_availableand the UI reverts + surfaces a specific toast pointing at the setup steps (install Tailscale →tailscale up→tailscale set --operator=<user>→ enable HTTPS in the tailnet admin console). Docker image now ships thetailscaleCLI pre-installed; users wire up by uncommenting the/var/run/tailscale/tailscaled.sockvolume mount indocker-compose.yml. The MagicDNS hostname is surfaced on the VP card with a copy-to-clipboard button (modernnavigator.clipboardin secure contexts,document.execCommandfallback for plain-HTTP contexts with textarea cleanup infinally). Cert renewal runs daily in-process and restarts only the affected VP's TLS listeners. New i18n keysvirtualPrinter.tailscaleDisabled.{title,description}+virtualPrinter.toast.{tailscaleNotAvailable,copyFailed}across all 8 locales with full translations. 3 new backend integration tests for the 409 guard, 2 unit tests for the_cancel_restart_taskself-await guard, 4 unit tests for the settings-dedupe migration, and 3 new frontend tests for the clipboard fallback path. Thanks to @legend813 for the original opt-out toggle PR that this was built on top of. -
Library Trash Bin + Admin Bulk Purge + Auto-Purge (#1008) — Library files now move to a trash bin on delete instead of being hard-deleted from disk, with a configurable retention window (default 30 days) before a background sweeper permanently removes them. Admins get a new "Purge old" action on the File Manager that shows a live preview of count + total size before moving every file older than N days (with an opt-in toggle for never-printed files, on by default) into the trash in one shot. A new Auto-purge setting in Settings → File Manager runs the same purge automatically on a 24-hour cadence when enabled — files still go to Trash first so the retention window remains the safety net; default-off so existing installs don't surprise anyone. Both the per-user delete flow and the admin bulk purge go through the same trash — regular users see and manage their own trashed files; admins see everyone's. External (linked) files bypass trash and keep the original hard-delete behaviour since their bytes aren't under Bambuddy's control. New
library:purgepermission gates the admin operations; retention is adjustable inline on the Trash page for admins. Adds nullabledeleted_atcolumn onlibrary_fileswith an index (dialect-aware migration:DATETIMEon SQLite,TIMESTAMPon PostgreSQL, since rawDATETIMEis SQLite-only syntax); everyLibraryFilequery site now routes through a newLibraryFile.active()classmethod so trashed rows can't leak into listings, print dispatch, MakerWorld dedupe, or stats. 17 new backend integration tests + 8 new frontend component/page tests; localised across all 8 UI languages. Thanks to @cadtoolbox for the proposal and the follow-up answers that tightened the spec. -
Archive Auto-Purge (#1008 follow-up) — Settings → Archives now has an auto-purge toggle plus a Purge archives now action on the Archives page header (next to Upload 3MF, mirroring File Manager's placement) that hard-deletes print archives not printed within a configurable window (default 365 days, min 7, max 10 years) with the same live-preview modal as the library purge. Reprinting an archive reuses the row and updates its
completed_at, so the purge honours the most recent print completion — a two-year-old archive you reprinted yesterday is not eligible for deletion. Unlike the library trash, archives are hard-deleted: print history is a decaying timeline, so there is no trash bin intermediate; download or favourite anything you want to keep first. The sweeper runs on the same 15-minute scheduler as the library trash but throttles actual purge runs to once per 24h so a tight tick cadence doesn't churn the DB. Each purged archive goes through the existing safety-checkedArchiveService.delete_archivepath so the 3MF, thumbnail, timelapse, source 3MF, F3D, and photo folder are all cleaned up together with the DB row. Gated by a new dedicatedarchives:purgepermission (Administrators group by default, backfilled on upgrade); 9 new backend integration tests; localised across all 8 UI languages. -
MakerWorld Integration — Paste any
makerworld.com/models/…URL on the new MakerWorld sidebar page to pull the full model metadata, plate list, creator/license info, and per-plate images, then one-click Save or Save & Slice in Bambu Studio / OrcaSlicer per plate. Closes the last workflow gap for LAN-only users who still had to keep the Bambu Handy app installed solely to send MakerWorld models to their printers. Reuses the existing Bambu Cloud login token for download authentication — no separate OAuth flow, no companion browser extension, no cookie paste.LibraryFilenow trackssource_type+source_url, so re-importing the same plate dedupes to the existing library entry. Search / browse-catalogue is intentionally out of scope because MakerWorld's public search endpoint isn't reachable from a server-originated request; the URL-paste flow covers the actual discovery pattern (Reddit / YouTube / shared links). Endpoint route (non-obvious, ~1 day of reverse engineering) — Pr0zak/YASTL#51 documented thatmakerworld.com-hosted design-service endpoints are cookie-gated (Cloudflare WAF serves a generic "Please log in to download models" to any non-browser bearer request), but the same backend is exposed unblocked atapi.bambulab.com. The working path turned out to beGET https://api.bambulab.com/v1/iot-service/api/user/profile/{profileId}?model_id={alphanumericModelId}withAuthorization: Bearer <cloud_token>— a different service (iot-service, notdesign-service) and a different host, accepting the same bearer the user already signs in with. Response carries a 5-minute-TTL presigned S3 URL (s3.us-west-2.amazonaws.com/…?at=…&exp=…&key=…). ThemodelIdquery param is the alphanumeric identifier (e.g.US2bb73b106683e5) that only appears in the design response body, not the integerdesignIdfrom the/models/{N}URL — so the import flow fetches design metadata first, readsmodelId, then calls iot-service. S3 presigned URLs must be fetched withurllib.request(not httpx / curl_cffi) because the signature is computed over the exact query-string bytes and any normalising encoder breaks it withSignatureDoesNotMatch400s (YASTL#52 describes the same issue). Every other published reverse-engineering project we evaluated (schwarztim/bambu-mcp, kata-kas/MMP) solved the gating by shipping "paste your browser cookie" flows; reusing the existing Bambu Cloud bearer is a substantially cleaner UX and the only fully-automated path. UI and UX features — per-plate picker with inline Save / Save & Slice in Bambu Studio / OrcaSlicer buttons, Import all to batch-import every plate sequentially, folder picker on the page (default: auto-created top-level "MakerWorld" folder), image gallery lightbox per plate (keyboard ←/→/Esc), two-column sticky layout with Recent imports sidebar (last 10 MakerWorld imports), per-plate inline follow-up actions after import (View in File Manager / Open in Bambu Studio / Open in OrcaSlicer / Remove from library), per-plate delete via the standard Bambuddy confirm modal (no browserconfirm()), elapsed-time + phase label ("Resolving … 3 s", "Downloading … 18 s") during the synchronous import POST so users see progress on large 3MFs, URL-change detection that drops the preview when the pasted URL diverges from the resolved one (fixes a class of "I thought I was importing model B but got A" dedupe confusion), rich error toasts per-phase, and the slicer-open path reuses Bambuddy's existing token-embedded library download (/library/files/{id}/dl/{token}/{filename}) so the handoff works even with auth enabled. Localised across all eight UI languages. Security hardening — the MakerWorld description HTML is user-authored and goes throughDOMPurify.sanitize()beforedangerouslySetInnerHTML.<img>tags inside summaries are rewritten to route through Bambuddy's/makerworld/thumbnailproxy so the SPA'simg-src 'self' data: blob:CSP stays unwidened. Thumbnail proxy now usesfollow_redirects=False(the host-allowlist guarantee is only meaningful on the initial URL — a 302 to169.254.169.254would otherwise bypass it). The 3MF CDN fetch sends onlyUser-Agent— the Bambu Cloud bearer is never forwarded to the CDN. S3 presigned-URL fetch uses aurllib.requestopener with a no-opHTTPRedirectHandlerfor the same reason. Filenames from MakerWorld responses areos.path.basename'd before persisting, so a maliciousname: "../../evil.3mf"cannot surface a path-traversal string into the DB / UI (on-disk storage uses a UUID filename regardless). New routes respect theMAKERWORLD_VIEW(resolve / recent-imports / status) andMAKERWORLD_IMPORT(import) permissions. SSRF guard on downloads rejects any host that isn'tmakerworld.bblmw.com,public-cdn.bblmw.com, or a.amazonaws.comsubdomain. Test coverage — 46 unit tests forservices/makerworld.py(header shape, API base,get_design/get_design_instances/get_profile,get_profile_download200/401/403/404/no-token,download_3mfSSRF rejection of 4 hostile hosts, S3 path delegation, CDN path with minimal headers, size-cap,_download_s3_urllibhappy/redirect/size/network paths,fetch_thumbnailwithfollow_redirects=False); 19 route tests (/resolve,/importwith folder autocreation + explicit folder + dedupe + filename basename + profile_id response,/recent-importswith empty-list / ordering / pydantic shape / limit clamping,_canonical_urlunit); 12 frontend tests (button labels, slicer-name interpolation, URL-change detection, inline post-import actions, Recent imports rendering, DOMPurify<script>strip). -
SpoolBuddy kiosk no longer shows main-app toasts — the global
ToastProvider(inApp.tsx) wraps both the main app routes and the SpoolBuddy kiosk routes, so the background-dispatch progress overlay (job percent, completion summaries, etc.) was rendering on the kiosk display alongside any in-flight prints. Added asetViewportSuppressedsetter on the toast context;SpoolBuddyLayoutflips it on mount and restores on unmount via a singleuseEffect. The state machine, dispatch-event subscription, and other tabs' toast UIs are untouched — only the visible viewport is hidden while a kiosk display is active. Trade-off accepted: kiosk-local one-shot toasts (plate-clear confirmation, quick-add errors) are also hidden, but the kiosk's UI already provides direct visual feedback (the plate-ready row vanishes on click; quick-add failures surface in the modal). UpdatedSpoolBuddyLayout.test.tsxto wrap inToastProviderand expand its lucide-react mock with the icons ToastContext imports. 2 new regression tests:ToastContext.test.tsx::viewport suppressionpins the suppressed-viewporthiddenclass toggle without affecting the underlying state, andSpoolBuddyLayout.test.tsx::suppresses the global toast viewport while mountedconfirms the kiosk layout flips suppression at mount and cleanup. -
Background-dispatch toast no longer reads as "frozen at 100%" for fast uploads — small files (a few hundred KB to a printer over LAN) finish FTP upload in <500ms, so the progress bar would jump to 100% and then sit there for ~1-2s while the printer's MQTT confirmation landed and the success toast replaced the dispatch toast. Now, when the byte-count reaches the total but the job status is still
processing(i.e. upload done, awaiting printer ack), the byte-count line is replaced with "Awaiting printer..." and the progress bar getsanimate-pulseto indicate continued activity. Translated across all 8 locales (backgroundDispatch.awaitingPrinter). 2 new tests inToastContext.test.tsx::background dispatch — upload-done UXcover the threshold (uploadProgressPct >= 99.9withprocessingstatus switches to "Awaiting printer..." + pulse) and the in-flight case (50.0%keeps the byte/percent counter, no pulse). -
SpoolBuddy kiosk: "Plate ready" pills under the printer status badges — when any printer reports
awaiting_plate_clear=true, a small amber pill appears in the dashboard's left column, sized to match the existing online/offline printer badges. Each pill shows the printer name plus a "Clear" action; tapping it callsPOST /printers/{id}/clear-plateand optimistically removes the pill from the UI before the WebSocket round-trip lands. Multi-printer setups (e.g. four H2Ds finishing at once) wrap inline viaflex-wrapso the dashboard stays compact instead of pushing everything else off-screen. The kiosk's API key already passes theprinters:clear_platepermission gate via the existing_APIKEY_DENIED_PERMISSIONSdenylist (the permission is intentionally not denied — clear-plate is an inventory-flow operation, not an admin one), so no auth wiring changes were needed. Translated across all 8 UI languages (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). 5 new regression tests inSpoolBuddyDashboard.test.tsx::plate-clear rowcover: row hidden when no printer is pending, mixed pending/non-pending printers (only the pending one gets a pill), title attr + pill text content + Clear label all rendered, clicking callsapi.clearPlate(printerId), the optimistic cache write makes the row vanish without waiting for a refetch, and three concurrent pending printers wrap inline in the sameflex-wrapcontainer. The mockuseTranslationwas upgraded to support{{var}}interpolation so future tests can assert on rendered i18n strings with arguments. -
Per-request trace ID column on every log line, plumbed through HTTP access log + application logs + response headers — Builds on the new uvicorn-access-log-into-bambuddy.log change below: the access line tells you who called an endpoint, but until now there was no way to tie that line to the application records emitted on the server side while handling that request. A new FastAPI middleware (
trace_id_middlewareinmain.py, sourced frombackend.app.core.trace) stamps each request with a fresh 8-char hex ID (or honours a sane inboundX-Trace-Idheader for cross-system correlation), stores it in aContextVarso any code in the request's call stack can read it, echoes it on the response asX-Trace-Id, and a newTraceIDFilterinjects it into everyLogRecordso the format string[%(trace_id)s]resolves to the right ID for the right request. ContextVars (rather thanrequest.state) are the right plumbing here because asyncio copies the current context into everyasyncio.create_task, so background work spawned from inside a request inherits the trace ID without explicit threading; the logging filter has no access to the FastAPI request object regardless. Records emitted outside any request scope (startup, MQTT callbacks, scheduler) get a stable-placeholder so the column stays visually aligned and missing values are obvious ingrep. InboundX-Trace-Idis hard-validated against a strict whitelist ([A-Za-z0-9_-]+, max 64 chars) before being honoured — a hostile or buggy caller cannot smuggle log-injection payloads (newlines, control chars, megabyte blobs) intobambuddy.logvia the trace-ID column; values that fail the gate silently trigger a freshly minted server-side ID rather than failing the request. Middleware is decorated AFTERauth_middlewareon purpose: Starlette stacks@app.middlewaredecorators LIFO so the last-decorated runs first inbound, making trace stamp the OUTERMOST layer — auth log lines and every record emitted on the way down to and back from the route handler all carry the same ID. Output now looks like2026-04-26 09:51:39,152 INFO [uvicorn.access] [a4f3b1e7] 192.168.1.42:54812 - "POST /api/v1/printers/1/print/stop HTTP/1.1" 200paired with the route handler's2026-04-26 09:51:39,158 INFO [bambu_mqtt] [a4f3b1e7] [SERIAL] Sent stop print command— onegrep a4f3b1e7away from the full causality chain. 30 new tests acrosstests/unit/test_trace.py(placeholder when no request scope, filter copies ContextVar value onto records, ID propagates into spawned tasks via asyncio context copy, concurrent requests don't leak IDs into each other, generator produces unique hex IDs, hostile payloads rejected by validator, max-length boundary, dash/underscore variants accepted) plustests/integration/test_trace_middleware.py(X-Trace-Id header echoed on response, body and header IDs match, each request gets a unique ID, generator format stays short hex, safe inbound IDs honoured, hostile inbound IDs replaced, overlong inbound IDs replaced, ContextVar reset cleanly after request).
Changed
-
AMS slot "Assign to inventory spool" picker now lists every spool, including RFID-tagged Bambu Lab ones (#1133) — The picker that opens from
<FilamentHoverCard>/ SpoolBuddy's slot-action sheet had two stacked filters that together blocked a real workflow: (1)AssignSpoolModalonly listed spools whosetag_uidANDtray_uuidwere both null, hiding any Bambu Lab spool that had been auto-created from RFID or scanned via SpoolBuddy NFC; (2)FilamentHoverCardrendered its inventory section (assign + unassign affordances) only when the slot's vendor was notBambu Lab, so even if you fixed the picker the button to open it wasn't visible on a BL slot. The use case both filters blocked: a user who has a Bambu Lab spool sitting in their inventory but doesn't want to scan it via SpoolBuddy NFC each time and just wants to pick it from the list. Both gates are gone now: the modal lists every spool that isn't already taken by a different (printer / ams_id / tray_id) tuple, and the hover-card inventory section renders for every vendor including Bambu Lab. The AMS-vs-external-slot distinction in the modal also collapsed — external slots (amsId 254/255) used to be the only path that allowed picking a tagged spool, and that special-case is now redundant. Empty slots (<EmptySlotHoverCard>in Bambuddy,slotActionPicker.tray === nullin SpoolBuddy) lost their assign affordance entirely: a physically empty slot has no spool to attach an inventory record to, and offering the action there only led to users assigning the wrong spool to a slot the printer hadn't actually loaded yet — assignment now requires a loaded slot. Thei18n.inventory.noManualSpoolskey (whose copy talked specifically about "manually added spools") was renamed toinventory.noAvailableSpoolswith new copy ("No spools available. Add a spool to your inventory or unassign one from another slot first.") since the empty-state premise changed; localised across all 8 languages with full translations. 5 net-new frontend tests in__tests__/components/FilamentHoverCard.test.tsx(assign/unassign buttons render forvendor: 'Bambu Lab', non-BL vendors unchanged, EmptySlotHoverCard renders no assign affordance, configure button still works on empty slots) plus the existingAssignSpoolModal.test.tsx"filters out BL spools" expectation was inverted to match the new contract and the empty-state test reworked to exercise the only remaining trigger (every spool taken by another slot). -
Inventory: "Delete Tag" button renamed to "Clear RFID Tag" (#729 follow-up) — The reporter mistook the button for a taxonomy-tag delete (it actually clears the RFID tag UID/UUID off the spool record so the row can be re-attached to a different physical spool). Renaming it to "Clear RFID Tag" + the success toast to "RFID tag cleared" removes the ambiguity. No behaviour change. Localised across all 8 UI languages with full translations.
-
Nozzle icon on the dual-nozzle status card (#1115) — the dual-nozzle active-extruder card on the printer status bar was the only card in that row without a theme icon (the Nozzle/Bed/Chamber temperature cards all carry a thermometer icon), which left the row looking visually uneven on H2D / H2S / H2C. Adds a small schematic nozzle icon (filament body + heater block + tip) above the L/R diameter labels, styled in amber-400 to match the card's active-extruder accent. SVG design contributed by @m4rtini2.
-
Slice tracker no longer shows the "embedded settings used" warning toast —
SliceJobTrackerContextwas emitting a yellow warning toast on every completed slice whose result carriedused_embedded_settings: true(the auto-fallback path that fires when the sidecar's--load-settingstriplet rejected the input). For 3MF inputs that fallback fires on essentially every slice in production (BambuStudio CLI segfaults silently on--load-settingsover 3MF, even with the broader strip applied — verified end-to-end with the new sidecar stderr capture), so the toast was firing on essentially every completed slice and adding noise without a useful action. Theused_embedded_settingsflag still lands onSliceResponse/SliceArchiveResponsefor tests + observability (test_library_slice_api.py:347continues to pin it); only the user-facing toast goes.slice.fallbackUsedEmbeddedremoved from all 8 locale files in the same change. -
Settings page: permission-gated instead of admin-only — the Settings sidebar entry has always been visible to any user holding
settings:read, but the route guard required admin role, so a non-admin withsettings:readwould see the entry, click it, and get silently redirected back to the dashboard. The route guard now matches the sidebar: any user withsettings:readcan open the page, and the individual tabs / cards continue to enforce their own per-feature permissions (users:read,groups:update,oidc:*, etc. — many of them admin-only, some not). Group editor routes moved to permission-based guards too (groups:createfor/groups/new,groups:updatefor/groups/:id/edit), so permission delegation works end-to-end. Admins retain full access since admins implicitly hold every permission. -
i18n: full key parity across all 8 locales —
enis the reference; every other locale (de,fr,it,ja,pt-BR,zh-CN,zh-TW) is checked identically and any drift fails CI. Until now, the parity script atfrontend/scripts/check-i18n-parity.mjsonly enforced parity forde/zh-CN/zh-TWand demotedfr/it/ja/pt-BRto an "informational" tier — drift was reported but never gated. Result: 78 missing keys in fr and it, 66 in pt-BR, 54 in ja, accumulated across every release that added new en strings. Backfilled real translations (not English fallbacks) for every gap:login.resetPassword.*(12 keys, fr/it/ja),printers.firmwareModal.*extension (7 keys × 4 locales) from the firmware modal redesign, the fullsettings.spoolbuddy.*device-control admin block (~40 keys) for unregister / reboot / shutdown / update / restart confirms, the kiosk-sidespoolbuddy.settings.*block (13 keys × 4) for backend & auth + diagnostics, and the newvirtualPrinter.archiveNameSource.*block from this release (#1152). The parity script itself dropped the two-tierSTRICT/infomachinery — every non-en locale is now treated equally — so any future feature that adds en strings without translating them everywhere fails CI uniformly. All 8 locales sit at 4492 leaves.
Fixed
-
In-app upgrade was hardcoded to
origin/mainand silently no-op'd whenever the latest release wasn't on main —_perform_updaterangit fetch origin main && git reset --hard origin/mainverbatim, regardless of which version GitHub's releases API reported as latest. So during any beta release cycle (when0.2.4b1lives on its own branch andmainstill points at the previous stable), users on the prior stable who clicked Apply Update saw the GUI report success but actually stayed pinned to the oldmainHEAD. The pre-existing pip-cwd and SSH-origin-clobber bugs in this same code path made it worse, but the underlying limitation was that the updater literally couldn't reach a non-main release. Fix: extract_discover_target_release(db)(mirrors the same release-API +include_beta_updatesselection logic the GUI's update-check route already uses), pass the resolved tag (e.g.v0.2.4b1) into_perform_update(target_ref), andgit fetch --prune --tags origin && git reset --hard <tag>. The fetch step now pulls--tagsso the tag ref is locally resolvable; the reset takes whatever ref the caller resolved instead of a hardcoded branch. Also makesapply_updatereturn a clear error if no release matches the user's channel rather than silently kicking off an update that can't land. Three new regression tests intest_updates_api.pycover (1)_perform_updateresets to the caller-supplied ref and fetches tags, (2)apply_updateplumbs the discovered tag through to_perform_update, (3)apply_updateerrors out cleanly when discovery returns no candidate. -
In-app upgrade clobbered SSH
originon developer checkouts — The in-app Apply Update path unconditionally rangit remote set-url origin https://github.com/maziggy/bambuddy.gitbefore fetching, on the assumption that systemd service users wouldn't have SSH keys configured. That assumption holds for production native installs, but anyone testing the upgrade flow against their own development checkout (whereoriginis legitimatelygit@github.com:maziggy/bambuddy.gitand authentication is via SSH keys) had their SSH origin silently rewritten to HTTPS — so the very nextgit pushprompted for HTTPS credentials they didn't have configured and bounced. Fix: the updater now reads the currentoriginfirst viagit remote get-url, parses the URL into an(owner, repo)pair (handling all four canonical forms —git@github.com:owner/repo[.git]andhttps://github.com/owner/repo[.git]), and only rewrites if it doesn't already resolve tomaziggy/bambuddy. Native installs with no remote set, or origins pointing at a fork / wrong repo, still get reset to the canonical HTTPS URL. Three regression tests intest_updates_api.pycover the parser, the SSH-preservation case, and the fork-rewrite case so a future refactor can't regress either side of the contract. -
Native-install in-app upgrade silently skipped
pip installand the new dependencies never landed — On a native install (where systemd setsDATA_DIR=$INSTALL_PATH/data), the in-app Apply Update button shipped the new code viagit reset --hard origin/maincorrectly but then loggedERROR: Could not open requirements file: [Errno 2] No such file or directory: 'requirements.txt'and continued without installing the new deps.pip install -r requirements.txtwas running withcwd=settings.base_dir, which on a native install resolves to the data dir (e.g./opt/bambuddy/data), not the source-code dir (/opt/bambuddy); pip doesn't walk up looking for the requirements file the waygitwalks up looking for.git, so the file wasn't found, the install was effectively skipped, and the user ended up with new code but stale dependencies — which surfaces as cryptic import / runtime errors on the next restart. Same bug affected the optionalnpm install/npm run buildstep (it testedfrontend_dir = base_dir / "frontend", which doesn't exist on native installs, and silently fell through to the pre-built static files). Fix: introducesettings.app_diralongsidesettings.base_dirpointing at the source-tree root, and runpip installand the npm steps withcwd=settings.app_dir. Git operations keep usingbase_dirsince they already worked (git walks up to find.git). Docker users were unaffected — Docker doesn't use the in-app updater (image pull replaces it). Regression test intest_updates_api.pymocks every subprocess invocation in_perform_update, captures their cwd, and asserts the pip step runs inapp_dirand thatrequirements.txtactually exists there, so a future refactor that re-introducescwd=base_dirfor the pip step fails CI before another user trips over it. -
Postgres restore from a SQLite Local Backup aborted with
cannot drop table printers— Settings → Backup → Restore on a Bambuddy running against external Postgres failed withasyncpg.exceptions.DependentObjectsStillExistError: cannot drop table printers because other objects depend on itwhenever the live database carried orphan tables from removed features — for example legacyspoolman_slot_assignments/spoolman_k_profilefrom an earlier Spoolman integration that has since been removed from the ORM but whose tables and*_printer_id_fkeyconstraints still sat in the live schema, pointing atprinters. The restore path (_import_sqlite_to_postgresinsettings.py) calledmetadata.drop_all, which only enumerates tables defined by SQLAlchemy ORM models and emits plainDROP TABLE(noCASCADE); Postgres correctly refused to dropprinterswhile external constraints still referenced it, the entire restore aborted before any rows landed, and the user was left without a working DB. The drop phase now executesDROP TABLE … CASCADEon every table in thepublicschema (via apg_tables-iterating PL/pgSQLDOblock, after FKs have been stripped from the ORM metadata) beforemetadata.create_allrebuilds the schema. CASCADE is the right tool for a destructive restore — the user has explicitly chosen to wipe the DB and replace it from backup, so taking out orphan tables alongside ORM tables is correct behaviour, not surprise data loss. SQLite restores are unaffected (they go through a separate path). Discovered while attempting to restore a 0.2.4b1 backup onto a Postgres instance that had been upgraded across the Spoolman integration rewrite. Two regression tests intest_postgres_restore_drop_cascade.pymock the Postgres engine, run_import_sqlite_to_postgresagainst a tiny SQLite source, and assert (1) the captured SQL stream contains a CASCADE-aware iteration overpg_tables(so a regression tometadata.drop_allfails CI loudly, before another user trips on it) and (2) the CASCADE drop is scoped toschemaname = 'public'so a shared Postgres instance holding non-Bambuddy data in other schemas isn't taken out by a restore. All 44 existing settings-API tests still pass unchanged. -
H2D Pro multi-plate dispatch double-/triple-fire (#1157) — Scheduling 3 plates of a multi-plate file to the same H2D Pro caused the scheduler to fire all three
project_filecommands within ~60 seconds, even though the printer hadn't transitioned out ofFINISHfor the first one yet. The H2D Pro can sit atFINISHfor 80–210 s after acceptingproject_filebefore thegcode_stateflips toPREPARE, and during that window the existing DBbusy_printersseed (querying queue items inprintingstatus) was empirically missing the in-flight item — observed in support logs as items 139/140/141 all dispatching with status='printing' yet only the third actually triggering a state transition. User-visible symptoms: layer count flapping, all queued plates showing as printing simultaneously, MQTT disconnect storms (33 in a single 5-minute window), eventual print failure. Root-cause fix is a defensive in-memory dispatch hold layer inprint_scheduler.py: when_start_printsucceeds we record(printer_id, dispatched_at, pre_state, pre_subtask_id), and the nextcheck_queuetick adds that printer tobusy_printersuntil either (a) the watchdog observes a state/subtask transition (success path — release immediately past a 60 s minimum cooldown), or (b) a 180 s hard timeout expires (escape hatch for lost MQTT sessions). The minimum cooldown also prevents a spurious double-dispatch if the printer pulses through PREPARE→RUNNING→PREPARE in the first second after acceptance. The hold is purely additive — sits alongside the existing seed query and_is_printer_idlechecks, doesn't depend on DB row visibility, doesn't depend onon_print_completefiring correctly. Per-printer isolation: a hold on printer A never blocks printer B. Edge cases covered by 12 new unit tests (test_scheduler_dispatch_hold.py): no-pre-state fallback (printer was offline at dispatch time), status-unavailable keeps hold (printer disconnected post-dispatch — don't release on missing data), idempotent release, hard-timeout self-cleanup, transition-during-cooldown still holds. The 90 s watchdog still owns the unhappy-path revert (queue item back topendingfor retry) — this fix runs alongside it, not instead of it. All 179 existing scheduler tests still pass unchanged. -
Project picker UX in archives (#1151) — The "Add to Project" submenu in the archive context menu was unusable past the visible fold once a project library exceeded the 300px scroll cap: any wheel scroll, arrow-key navigation, or scrollbar click slammed the entire context menu shut. Root cause was a capture-phase
document.scrolllistener inContextMenuthat fired on internal submenu scrolls too — the listener now checksmenuRef.current.contains(e.target)and ignores scrolls inside its own subtree. Project lists are now sorted alphabetically by name (localeCompare) at every assignment site (Archives context-menu submenu ×2, BatchProjectModal, EditArchiveModal, "review new uploads" panel, FileManagerPage project-picker) instead of newest-first from the API. The Archives "Add to Project" submenu and BatchProjectModal both gain a search input (rendered only when there are >5 projects, so small libraries stay clean) that filters the list by name as you type — Enter picks the first match. Newarchives.menu.searchProjectsi18n key in all 8 locales (en/de fully translated, the six others seeded with English copies pending native translation, matching the project's existing flow). -
OIDC
auto_link_existing_accountsnow works with custom email claims (Azure Entra ID) (#1088) —auto_link_existing_accountswas previously blocked unless bothemail_claim='email'andrequire_email_verified=True. This also rejected Azure Entra ID configurations usingpreferred_usernameorupnas the email claim — the recommended setup for that provider, which does not sendemail_verified. The guard now only blocks the genuinely unsafe combination (Fall B):email_claim='email'+require_email_verified=False. Custom-claim configurations (Fall C) never consultemail_verifiedat all, so there is no verification-bypass risk on that path. All five enforcement layers (DB CHECK constraint, schema validators for create and update, route combined-state guard, DB migration for existing installations) have been updated consistently. Security note: custom claims are safe for auto-link only when the claim value is tenant-administered. If your IdP allows end users to self-assert the claim's value, do not enable auto-link. An in-app warning is shown in the OIDC provider form when this combination is configured. -
OIDC settings form: "Require email verified" toggle no longer jumps layout when auto-link is enabled — When
Auto-link existing accountswas toggled on, the shorter description text caused theRequire email verifiedtoggle to reflow next toAuto-linkin the flex container instead of staying on its own row. Both toggles now havew-fulland always occupy a full row regardless of description length. -
P1P print dispatch failed with
0500_4003 "can't parse print file"when the printer was slow to acknowledge (#1150, reported by @d3ni3) — On a P1P at firmware 01.10.00.00 the printer can take up to ~135 seconds to actually start parsing a freshly uploaded.3mfafter the MQTTproject_filecommand lands; FTP STOR returns 226 cleanly and the upload is intact, butgcode_statestays atIDLEandsubtask_iddoesn't advance until the printer's slow internal parse completes. Both dispatch watchdogs (_verify_print_responseinbackground_dispatch.pyand_watchdog_print_startinprint_scheduler.py) interpreted the missed transition as a half-broken MQTT session — the original #887/#936 condition where telemetry kept arriving but our publishes were silently swallowed — and calledforce_reconnect_stale_sessionto wipe paho's QoS-1 queue and reconnect with a fresh client_id. That reconnect mid-parse is precisely what makes the P1P emit0500_4003: the new MQTT session interrupts the in-progress parse on the printer side and the printer reports the file as unparseable. The repro: send a print job, wait 15 seconds while the printer is still parsing, watch the watchdog force-reconnect, watch the printer fail with the parse error, retry — same loop. Sending the same file from BambuStudio worked because BambuStudio doesn't reconnect MQTT mid-parse. The fix uses the printer'sgcode_filefield as a definitive discriminator between #1150 (slow parse) and #887/#936 (half-broken session), since both look identical from telemetry alone: in both cases push_status keeps flowing,statestays unchanged, andsubtask_idstays at the pre-dispatch value. The distinguishing signal: when the project_file command actually lands on the printer side, the printer'sgcode_filefield updates in push_status to reflect the newly-uploaded file; if the publish was silently swallowed (#887/#936), the field stays at whatever the printer was previously showing. Both watchdogs now capturepre_gcode_filealongsidepre_stateandpre_subtask_idfromprinter_manager.get_status()before sending the publish, then compare against the printer's currentgcode_fileafter the watchdog times out. If the value changed → command landed → log a#1150warning explaining the skip and leave the MQTT session alone. If the value is unchanged → publish was silently swallowed → fall through to the originalforce_reconnect_stale_sessioncall so the #887/#936/#1136 zombie-session recovery is preserved exactly. The user-facing dispatch still fails on timeout (correctly — the print didn't start within the timeout window so the job is marked failed), the queue item still reverts topendingso the scheduler can retry, and the next dispatch attempt proceeds against the same intact MQTT session that was about to start the print. Pairs with the 15s → 90s timeout bump that already shipped in commit9d041868(the original 15s timeout was a separate v0.2.3.2 limit). Caveat acknowledged in code comments: in a retry-same-file slow-parse scenario the printer'sgcode_filelooks identical before and after the publish lands, so the watchdog falls through to the original reconnect path and the user still sees0500_4003on that specific retry — accepted to avoid breaking the half-broken-session recovery, which is the more impactful regression of the two. 4 new unit tests covering both watchdogs: skip reconnect whengcode_filechanged (the #1150 fix), reconnect whengcode_fileis unchanged (the #936 protection preserved), skip reconnect whenpre_gcode_file=Noneand current is non-None (printer just connected), reconnect whenpre_gcode_filearg is omitted (backward-compat for callers we haven't updated). All 439 existing dispatch / scheduler / mqtt tests still pass unchanged. -
3MF profile-driven slicing silently produced wrong-printer output (every 3MF slice fell back to the source's embedded printer regardless of the picked profile) — Two stacked bugs in the slice pipeline. (1) Pre-forward strip removed too much.
_strip_3mf_embedded_settingswas scrubbing all four embeddedMetadata/*.configfiles before forwarding the 3MF to the sidecar, on the theory that--load-settingswould then take precedence cleanly. That theory was wrong:Metadata/model_settings.configcarries the plate definitions the CLI needs to map--slice Nto a real plate, andslice_info.config/project_settings.configsupply baseline config the CLI'sStaticPrintConfigspass needs to even start. Stripping any of them caused the CLI to silently exit immediately after "Initializing StaticPrintConfigs" — exit code 0, noresult.json, no stderr — which the sidecar treated as failure and Bambuddy then masked by falling back toslice_without_profilesusing the un-stripped bytes (and the source's embedded printer). Net effect: every 3MF slice with profiles silently produced wrong-printer output. The strip is now gone from the slicer dispatch path entirely; original bytes go to the sidecar so--load-settingsoverrides only the specific fields the user changed (printer/process/filament) while the embedded plate / model definitions remain intact. (2) Standard-tier preset stubs were missing thetypefield._resolve_standardinpreset_resolver.pyemitted{"name": ..., "inherits": ..., "from": "system"}for the bundled tier, but the CLI's preset parser also requires atypediscriminator (machine/process/filament) on every loaded settings file — without it the CLI silently rejects withrc=-5("input preset file is invalid"), which the same masking fallback then turned into another wrong-printer slice. New_SLOT_TO_PROFILE_TYPEconstant maps each slot to its required type, and the stub now emits the right value per slot. Tests: integration test renamed from "strip removes all four configs" totest_3mf_input_forwarded_unmodified_to_sidecar— asserts everyMetadata/*.configplus3D/3dmodel.modelis preserved verbatim in the multipart body the sidecar receives. Preset-resolver test updated for the new stub shape; newtest_standard_emits_correct_type_per_slotpins each (slot → type) pairing. Pairs with the orca-slicer-api fork'sbambuddy/profile-resolverbranch which now emitsdetailson itsAppErrorresponses and captures CLI stdout/stderr in the failure path so future regressions of this shape produce a real error message instead of a silent fallback. -
Sliced-archive card listed every project-wide AMS slot instead of just the filaments the print actually used —
slice_and_persist_as_archivepreviously copiedfilament_type/filament_colorfrom the unsliced source archive verbatim, which inherited every project-wide AMS slot configured in the source'sproject_settings.config(16+ swatches on the card for what was actually a 2-color print). The new archive row now reads those fields from the sliced output'sMetadata/slice_info.configviaThreeMFParser(which already gates onused_g > 0per-slot), falling back to the source archive's values only when parsing the new 3MF failed. Test intest_archive_copy.py::test_filament_metadata_only_includes_filaments_with_used_gbuilds a 4-slot fixture where slots 2 and 4 haveused_g=0and asserts both type and color outputs exclude them. -
Slice modal had no warning when the picked printer profile didn't match the source 3MF's bound printer — silent wrong-printer output — Both BambuStudio and OrcaSlicer CLIs reject
--load-settingsfor a printer different from the one the source 3MF was originally bound to (rc=-16"current 3mf file not support the new printer") because the cross-printer "convert project" flow is desktop-Studio only; the slice would then fall back to embedded settings and produce a file sliced for the wrong printer that errored at print dispatch time with "File was sliced for A1, but printing on H2D". The plates response now exposessource_printer_model(read fromproject_settings.config'sprinter_modelfield, with fallback to stripping the nozzle suffix offprinter_settings_id); the SliceModal compares it against the picked printer profile name (substring match against the model prefix, e.g."Bambu Lab H2D 0.4 nozzle"matches"H2D") and surfaces an inline amber warning explaining the limitation, plus disables the Slice button while the warning is up so users can't dispatch a guaranteed-wrong slice. Cloud presets with arbitrary user-chosen names (e.g."My Custom X1C") and legacy 3MFs withoutproject_settings.printer_modelfall through to no-warning, which is a reasonable default — the user picked it knowingly. Newextract_source_printer_model_from_3mfhelper inthreemf_tools.pywith 6 unit tests covering missing/direct/nozzle-stripped/corrupt-JSON paths; 3 frontend tests in SliceModal pinning the warning + disabled-button on mismatch, no-warning on match, and no-warning when the source model is unknown. New i18n keyslice.printerMismatchlocalised across all 8 UI languages. -
Sliced output of a "single-color" plate had filaments the user never picked — When a multi-color project (e.g. a MakerWorld Stormtrooper helmet with white shell + grey support filament configured project-wide) was sliced for plate 1 (which only paints with white), the resulting
.gcode.3mf'sslice_info.confighad two filaments — white (the user's pick) and grey (a colour the user never chose). Root cause: the SliceModal was sending only the slots the picked plate consumed, but the slicer CLI requires a profile per project AMS slot — when fewer were supplied, the CLI silently substituted the missing slots from the source 3MF's embedded filament metadata, leaking the original creator's grey support filament into the user's output. Same silent-fallback class as the strip-removal bug. Fix: backend's/filament-requirementsendpoint now returns the FULL project AMS slot list with aused_in_plate: boolflag per entry (computed from the cached preview slice for unsliced files; alwaystruefor sliced files sinceslice_info.configalready pre-filters byused_g > 0). The SliceModal renders one dropdown per project slot — slots flaggedused_in_plate=trueare editable as before, slots flaggedused_in_plate=falseare auto-picked from project metadata via the existing(filament_type, filament_colour)scoring path and disabled with a "— not used by this plate" suffix on the label, so the user only interacts with what matters for their plate while the wire format always carries a profile per project slot. 2 new frontend tests pin the disabled-row rendering and the full-list-on-submit invariant. New i18n keyslice.notUsedByPlatelocalised across all 8 UI languages (English + German fully translated, the six others seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features). -
"Analyzing plate filaments…" spinner gave no signal that anything was happening on the first Slice-modal open for an unsliced project file — On a multi-color 3MF without slice_info data, the backend runs a preview slice via the sidecar to discover which AMS slots the picked plate actually consumes. That's the only source of truth: tried two heuristics — painted-face quadtree scan (silently missed extruders when
object_idmapping betweenmodel_settings.configand3D/3dmodel.modeldiverged, surfaced as a single dropdown for a 4-color print) and project-wide AMS list (over-rendered every plate to the project's full slot count) — and both produced wrong counts on real-world multi-color projects. Reverted to preview-slice-as-source-of-truth. The result is cached per(kind, source_id, plate_id, content_hash)so re-opens of the same plate are instant, but the first open on a complex model is a real slice (multi-second to multi-minute). The inline spinner now shows elapsed seconds and, after 5s, a hint explaining that this is a one-time preview slice and re-opens will be instant — addresses the original "is anything happening?" complaint without sacrificing correctness. Project-wideextract_project_filaments_from_3mfremains as a final fallback when the sidecar isn't configured. New i18n keyslice.analyzingPlateFilamentsHintlocalised across all 8 UI languages (English + German fully translated, the six others seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features). -
Settings warning when OrcaSlicer is selected as the preferred slicer — OrcaSlicer 2.3.2 and 2.4.0-dev (latest nightly as of 2026-04-28) have two upstream CLI bugs that together block slicing on most Bambu-authored multi-color / H2D 3MFs: (1) a SIGSEGV in the multi-extruder filament-resolution path on painted 3MFs (OrcaSlicer/OrcaSlicer#12426), and (2) the CLI strict-validates parameter values that BambuStudio writes by default —
solid_infill_filament: 0,tree_support_wall_count: -1,prime_tower_brim_width: -1— and exits 238 withParam values in 3mf/config error: ... not in range, even though OrcaSlicer's own GUI tolerates these (OrcaSlicer/OrcaSlicer#13386, filed alongside this change with a minimal repro 3MF). Both bugs verified reproducible on the latest nightly build before filing. Settings → Workflow → Slicer card now renders an inline amber alert under the preferred-slicer dropdown whenorcasliceris the current selection, linking out to both upstream issues and recommending Bambu Studio until upstream fixes land. The OrcaSlicer option is intentionally left pickable rather than disabled — users who only slice STLs or single-color 3MFs aren't affected by either bug, and forcibly disabling would also affect them. Localised across all 8 UI languages (English + German fully translated). -
Live progress for the SliceModal's filament-analysis preview slice + URL-decoded filenames in the toast — Two follow-ups to the live slicer-progress feature: (1) the modal's "Analyzing plate filaments…" preview slice (the real slice that fires before profile picking, to discover which AMS slots an unsliced plate consumes) now shows the same stage + percent live updates as the user-initiated slice. The frontend generates a per-(source, plate) request_id, forwards it via a new
request_idquery param on/library/files/.../filament-requirementsand/archives/.../filament-requirements, the backend plumbs it throughslice_without_profilesto the sidecar, and a newGET /api/v1/slicer/preview-progress/{request_id}proxy endpoint forwards browser polls to the sidecar's/slice/progress/:requestId(CORS-safe — the browser can't reach the sidecar directly). The inline spinner and a new persistent toast both renderAnalyzing {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}while the preview runs; toast dismisses when filaments arrive. (2) MakerWorld imports were persisting URL-encoded filenames (stormtrooper-helmet%20h2d.3mf) verbatim because MakerWorld's API returns the same percent-encoding it uses on its CDN URLs. The import path nowurllib.parse.unquotes both the manifest-supplied name and the URL path-tail fallback before passing tosave_3mf_bytes_to_library, plus the frontend defensivelydecodeURIComponents in the slice toast and analysis-spinner messages so already-imported rows display cleanly without a backfill migration. Falls back to the raw string on malformed encodings (%XYwhereXYisn't hex). New i18n keysslice.previewToast+slice.previewWithProgresslocalised across all 8 UI languages (English + German fully translated). -
Live slicer progress in the persistent slice toast — The persistent slice toast already showed elapsed time + a spinner so the user could see the slice was still running, but for long slices on complex multi-color models that "is anything happening?" gap could last minutes. Bambuddy now wires up the slicer CLI's structured progress channel end-to-end, so the toast renders concrete stage labels + live percent —
Stormtrooper.3mf — Generating G-code (75%) — 47s— through the entire slice. Sidecar (bambuddy/profile-resolverbranch of orca-slicer-api): switched the sync/sliceroute fromexecFiletospawnso the process can run alongside an FIFO reader; on each request the route generates (or accepts a caller-supplied)requestId,mkfifos${workdir}/progress.fifo, passes--pipe ${fifo}to the OrcaSlicer / BambuStudio CLI, and reads the structured JSON-line progress events the slicer emits ({"message":"Generating G-code","plate_count":1,"plate_index":1,"plate_percent":80,"total_percent":75}) into a per-processProgressStorekeyed byrequestId. NewGET /slice/progress/:requestIdreturns the latest snapshot; entries linger 30s after slice completion so the caller's last poll still reads the terminal "All done, Success" frame instead of a 404. Both slicer forks share the same code lineage from PrusaSlicer'sBackgroundSlicingProcess, so OrcaSlicer 2.3.2 and BambuStudio 02.06.00.51 emit identical JSON keys (verified by tracing the binary). Bambuddy backend:slicer_api.slice_with_profilesacceptsrequest_id+on_progresscallback and spawns a 1Hz parallel poller that hits the sidecar's progress endpoint while the blocking POST is in flight;SliceDispatchServicegained aset_progress(job_id, snapshot)method and aprogressfield onSliceJob; the slice routes now generate a uuidrequest_idand wire a callback that forwards each snapshot onto the dispatcher.GET /slice-jobs/:idincludesprogresson every poll. Frontend:SliceJobTrackerContextreads the newprogressfield and re-renders the persistent toast with{name} — {stage} ({percent}%) — {elapsed}whenever a useful frame is present, falling back to the existing elapsed-time-only message when the sidecar hasn't emitted anything yet (early "Initializing" phase) or doesn't support progress (older sidecars without the FIFO wiring). 12 sidecar unit tests for the JSON-line parser + ProgressStore (cancellation/grace-window, malformed lines, missing fields), 3 dispatcher tests forset_progress(attach/replace/clear, unknown-job-id silent ignore), 3 slicer_api tests for the form-field forwarding + on_progress callback wire-up + 404 short-circuit, 2 frontend SliceJobTracker tests pinning the new toast format and the no-progress fallback. New i18n keyslice.runningWithProgresslocalised across all 8 UI languages (English + German fully translated, the six others seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features). Graceful when the sidecar lacks--pipesupport (tested live: OrcaSlicer 2.3.2 + BambuStudio 02.06.00.51 both work; older sidecars without the new endpoint return 404 and the toast cleanly degrades to elapsed-time-only). -
No visual indicator while a slice job was running — users couldn't tell if a long slice was still progressing or had hung — Previously SliceJobTrackerProvider emitted one transient toast on enqueue ("Slicing X in the background…") and one on completion ("Sliced X"), with nothing in between. For large multi-color models that take 30s–several minutes to slice, the start toast auto-dismissed after 3s and left a UX dead zone where users would ask "is it still slicing?". The tracker now opens a persistent
slice-job-{id}toast with a spinner that updates every second showing elapsed time + phase ("Queued: X — 4s" → "Slicing X — 47s"), then is replaced by the existing transient success/error toast on terminal state. Polling cadence (1.5s) is unchanged — a separate 1Hz tick re-renders just the elapsed-time counter so the toast stays smooth even if the backend is slow to respond. Time format compresses gracefully past 60s ("1m 5s") and 60m ("1h 12m"). 4 new unit tests inSliceJobTrackerContext.test.tsxcovering: persistent toast renders at t=0 (no wait for first tick), elapsed time updates each second while running, success completion replaces persistent with transient "Sliced X", failure replaces with transient error toast carrying the sidecar'serror_detail. New i18n keysslice.queuedToast/slice.runningToastlocalised across all 8 UI languages (English + German fully translated, the six others seeded with English copies pending native translation, matching the project's existing flow for newly-added user-facing features). -
MakerWorld URL-paste resolver listed plate instances without showing which printer each was sliced for — MakerWorld's
/instances/hitsendpoint omits the per-instance compatibility info that lives ondesign.instances[].extention.modelInfo(compatibility= primary printer the instance was sliced for,otherCompatibility= additional printers the uploader marked it compatible with), so every instance row in the resolved-design preview looked identical and users blindly picked the first one regardless of whether it matched their printer — leading to "I downloaded the H2D version and got A1 g-code" complaints. The resolve route now joins both endpoint payloads by instance ID and forwards both fields onto each hit; the MakerWorld page renders "Sliced for {primaryPrinter}" + (when present) "Also marked compatible: ..." per instance row. Backend tests intest_makerworld_routes.py::TestResolvecover the merge happy path (compatibility lists land on the right hits) and the "missing modelInfo" fallback (older designs / hits without a matching design.instances entry don't crash the response, just lose the optional fields). New i18n keysmakerworld.slicedFor/makerworld.alsoCompatiblelocalised across all 8 UI languages. -
Moving a file to an external folder updated the DB row but never wrote the bytes to the mount (#1112 follow-up — confirmed by @Carter3DP after testing 0.2.4b1) — Carter's report read "the file appears in Bambuddy but not physically on the external folder", which traced to
move_filesonly updatingfile.folder_idin the DB while leaving the bytes in the internallibrary_files_dir. Direct upload to a writable external folder was already fixed in 0.2.4b1; the move path was not. Cross-boundary moves now physically relocate the bytes through a new_move_file_byteshelper. Same-boundary moves (managed → managed) keep the existing DB-only fast path because the file's on-disk location doesn't depend on which managed folder owns it. The helper handles four flows: managed → external (copy bytes to<external_path>/<filename>, flipis_external=True, store the absolute path, unlink the managed source), external → managed (copy bytes into internal storage with a fresh UUID name, flipis_external=False, store the relative path, unlink the external source, recomputefile_hashsince scan-tracked rows historically carryfile_hash=None), external → external (same as managed → external), and managed → managed (DB-only). Copy-then-unlink ordering means a partial copy followed by a failed unlink leaves both copies on disk rather than losing the source if the target write fails halfway through on a flaky NAS mount. Failedshutil.copy2cleans up partial dest before raising. Defence-in-depth checks block: source on a read-only external mount (move = delete-on-source which a RO mount can't fulfil — would copy-then-fail-to-unlink and silently duplicate the file), filename collisions on the target mount (won't silently overwrite a file the user already has on the NAS), traversal-style filenames afterPath.resolve(), missing source on disk, andos.access(W_OK)on the target mount. Each skip carries a structured{file_id, code, reason}entry in a newskipped_reasonsfield on the response so the UI can surface "5 of 10 files skipped: 3 had filename collisions on the NAS, 2 are no longer on disk" instead of a blank "skipped: 5". The original{moved, skipped}numeric counters are preserved so existing frontend code that only reads those keeps working unchanged. Six new integration tests intest_external_folders_api.py::TestCrossBoundaryMovecovering: managed → external relocates bytes (the actual #1112 fix — bytes land on mount, internal source removed, DB row matches reality), external → managed relocates bytes (symmetric path including hash recompute), name collision on target external mount skips withcode: "name_collision"and leaves the pre-existing target file intact, source on read-only external mount skips withcode: "source_readonly", managed → managed stays DB-only (file_path doesn't change, no shutil.copy), andskipped_reasonsis always present (empty list when nothing skipped) so frontend code can treat it as the source of truth without optional-chaining. -
bambuddy.logfilling withException terminating connection ... CancelledError+database is lockedcascades on long uploads (#1112 follow-up, surfaced by @Carter3DP's support package) — Two-part fix to a single root cause: Starlette'sBaseHTTPMiddleware(which FastAPI's@app.middleware("http")decorator uses under the hood) cancels the inner task scope when a client disconnects mid-request — common on long multipart uploads where the client times out before the server's response. Pre-fixget_dbonly caughtException, butCancelledErroris aBaseException, so cancellation skipped the rollback path entirely; the SQLite write lock stayed held until the connection was eventually GC'd, producing the(sqlite3.OperationalError) database is lockedcascade againstruntime_secondsupdates and other tight-loop writers in @Carter3DP's log. Postgres users would see pool exhaustion / "QueuePool limit overflow" instead of file-level lock contention, but the leak shape is identical. (1)get_dbnow catchesBaseExceptionsoCancelledErrortriggers rollback, and wraps bothrollback()andclose()inasyncio.shieldso the cleanup completes even when the await itself is being cancelled by the same cancel scope. The SQLite write lock is released promptly; the connection returns to the pool instead of leaking until GC. (2) ACancelledPoolNoiseFilter(newlogging_filters.pyfilter, attached tosqlalchemy.pool) drops the residual log noise that pre-existing pools still emit during their own cleanup — both theException terminating connection ... CancelledErrorrecords (matched on prefix + cancellation-drivenexc_info, including chained__cause__/__context__) and the symptomaticgarbage collector is trying to clean up non-checked-in connectionrecords. Real pool problems — broken connections, network hiccups, exhaustion — keep flowing because they carry a different exception chain or a different message prefix; verified bytest_keeps_terminate_with_real_oserrorandtest_keeps_unrelated_pool_message. 13 new regression tests acrosstest_get_db_cancel_safety.py(commit on clean exit, rollback on regularException, rollback onCancelledError— the actual #1112 fix, close runs even if rollback raises, close failure on clean exit doesn't propagate, both rollback + close go throughasyncio.shield) andtest_cancelled_pool_filter.py(drops cancellation-driven terminate, drops GC-cleanup, keeps realOSErrorterminate, keeps terminate withoutexc_info, keeps unrelated pool messages, drops chained-causeCancelledError, defensive guard against self-referential cause chains). Applies to SQLite and PostgreSQL —get_dbis dialect-agnostic and the filtered messages come from basesqlalchemy.poolnot from any specific dialect. -
Windows install:
bambuddy.logfilling withWinError 10054 — _ProactorBasePipeTransport._call_connection_losttracebacks (#1113, reported by @cadtoolbox) — Cosmetic-but-noisy. When a printer / MQTT broker / camera RSTs a TCP socket instead of FINing it (offline X1Es in @cadtoolbox's setup, network gear that drops idle TCP, the printer firmware's own watchdog), Windows asyncio's Proactor cleanup path triessocket.shutdown(SHUT_RDWR)on the already-dead socket and hitsWinError 10054. Application-layer reconnect logic (paho-mqtt, httpx) handles the actual disconnect fine — paho retries, MQTT comes back, telemetry resumes — so the traceback is pure asyncio bookkeeping noise, but it fired multiple times per minute on @cadtoolbox's 9-printer setup with 5 offline X1Es and was the first thing in the sanitized log. Adds a customloop.set_exception_handler(newbackend/app/core/asyncio_handlers.py) installed on Windows only that pattern-matches the specific_call_connection_lostcleanup-RST signature (three signals together:sys.platform == "win32", the exception isConnectionResetError, and the asyncio message string contains_call_connection_lost) and downgrades it to DEBUG. RealConnectionResetErrors raised inside application coroutines (different message string) and other Proactor cleanup errors (BrokenPipeError,ConnectionAbortedError— same callback site, distinct signal worth keeping visible) all pass through toloop.default_exception_handlerunchanged. Linux / macOS use the Selector event loop and never hit this codepath, soinstall_proactor_reset_filter()is an explicit no-op there with aFalsereturn — verified bytest_install_is_no_op_on_non_windows. 9 unit tests intest_asyncio_handlers.pycover: discriminator matches the exact reported signature, rejects unrelatedConnectionResetErrors, rejectsBrokenPipeErroreven on the same callback site, rejects when no exception object is present, install is platform-gated, install wires the handler onto the loop, suppression doesn't reach the default handler, and unrelated exceptions still hit the default handler. Wired fromlifespanstartup before any task can spawn that might trip it. -
Auto-Print G-code Injection: start snippet landed before printer startup, and
{placeholder}substitution was silently broken (#422 follow-up) — Two compounding bugs surfaced by @pleite (Swapmod) and @DevScarabyte (multi-height test prints) on the initial #422 ship: (1) Start snippets were prepended to the entireplate_X.gcodecontent, which placed them before the printer's bed-heat / homing / nozzle-prime sequence — so a Swapmod start snippet that assumed nozzle-at-temp ran on a cold printer. The injection now anchors at; MACHINE_START_GCODE_END(the marker sitting at the bottom of every Bambu/Orca slicer'sMACHINE_START_GCODEblock, afterM109wait-for-temp), matching where a slicer-side custom-start-gcode would land. Files without the marker (older slicer versions) keep the prepend behaviour as a fallback with a warning log. (2) Slicer-style placeholders likeG1 Z{max_layer_z} F600were written verbatim to the output gcode — the printer firmware then parsedZ{max_layer_z}asZ1and crashed the head into the print on a 60mm-tall model (a real safety issue: prints damaged, top glass + AMS pushed up off the printer when the model was taller than the hard-coded park height). Added a header parser that reads the 3MF's; HEADER_BLOCK_START..ENDblock (lowercased keys,[units]suffix stripped, spaces → underscores) and a Prusa-style{name}substitution pass that runs over both start and end snippets before injection. Supported placeholders:{max_layer_z}/{max_print_height}(top-layer Z),{total_layer_number}/{total_layers},{total_filament_weight},{total_filament_length}, plus any other normalised header key from the source file. Unknown placeholders are left in the snippet verbatim with a warning log — a typo never silently expands to an empty string and the firmware never receives a malformedZparameter. 16 new regression tests intest_gcode_injection.pycovering: start snippet anchored to the marker (printer startup runs first, snippet sits betweenM109 S220and the marker, file head untouched), missing-marker fallback path, end snippet still appended at EOF,{max_layer_z}resolved through the alias map, direct-key substitution from the normalised header, unknown-placeholder pass-through, and direct unit tests for each new helper (_parse_3mf_gcode_header,_substitute_placeholders,_inject_start_at_marker). Wiki page documents the supported placeholder list with a safety warning specifically calling out{max_layer_z}for park moves. -
Camera page ignored
?fps=NURL parameter (#1131 diagnostic) —CameraPage.tsxhard-codedfps=15in the stream URL and never read the URL query string, so/camera/1?fps=5(and similar diagnostic suggestions for the freeze report) were silent no-ops. The siblingStreamOverlayPagealready honoured?fps=correctly; the bug was thatCameraPagewas the gap. Now readssearchParams.get('fps')viauseSearchParams, parses it, falls back to 15 on missing/non-numeric, clamps to the backend's 1–30 range, and threads the resulting value into the stream URL. Backendgenerate_rtsp_mjpeg_streamalready accepted the parameter and re-clamps per-model (chamber-image A1/P1 capped at 5, RTSP capped at 30). 5 new regression tests inCameraPage.test.tsx::fps URL parameter (#1131)cover default-15, honoured value, clamp-above-30, clamp-below-1, and non-numeric fallback — same matrixStreamOverlayPage.test.tsxalready pins. Independent of the underlying freeze investigation in #1131; surfaced while triaging that report. -
Reprint-from-archive failed with
0500_4003SD R/W errors after a stuck dispatch, fixable only by restarting the container (#1136) — Reported by @smandon: reprinting from archives sometimes fails immediately with MicroSD R/W exception errors, with the printer's MQTT push referencing a 3MF file from a different unrelated archive (WARIO_Wall_decor_-_NO_AMS.3mfwhile the user was actually trying to printCable_Organiser_Cable_Clip.3mf). Once it starts happening, every subsequent reprint hits the same error until the container is restarted. Root cause traced from his support package log to paho-mqtt's client-side QoS 1 queue: when the printer's command channel goes half-broken (telemetry still flowing, publishes silently dropped — same #887/#936 pattern), Bambuddy's 15s dispatch deadline expires (background_dispatch.py:993) and callsforce_reconnect_stale_session(). That function was force-closing the underlying socket so paho's auto-reconnect would kick in — but the samemqtt.Clientinstance, sameclient_id, and same in-process QoS 1 queue stayed alive across the reconnect. Any unacked publish from the broken session — typically the just-sentproject_filefor the new archive — got replayed verbatim on the new connection. And because the in-process queue accumulates across multiple stuck dispatches within one Python process, by the second or third stuck reprint there were several staleproject_file/resume/stop/clean_print_errorcommands queued up and replaying together. The printer received the flood, tried to load whichever stale path the firmware latched onto last, found a file that no longer existed on its SD card →0500_4003. Container restart was the only thing that fixed it because it was the only thing that wiped paho's in-process queue. Replaced the socket-close with a context-aware reconnect:force_reconnect_stale_session()andcheck_staleness()now go through a routing helper_reset_client_for_reconnect()that picks the right teardown strategy based on caller context. Async-context callers (the dispatch deadline path —background_dispatch.py:993— which is the actual #1136 trigger, plus FastAPI route handlers viacheck_staleness) get the hard-reset path:client.disconnect()(broker sees DISCONNECT and drops the session immediately, sinceclean_session=True),client.loop_stop()(kills the paho network thread, taking its QoS 1 queue with it), nulls outself._client, and callsself.connect()to construct a freshmqtt.Clientwith an incrementedclient_id. New connection starts genuinely empty, no replay possible. Paho-network-thread callers (the developer-mode probe andams_filament_settingzombie detection inside_update_state, lines ~2604 and ~2623) keep the socket-close fallback — callingloop_stop()from inside the network thread would self-join and deadlock, so the safe pattern there remains "close the socket and let paho's own loop detect it and auto-reconnect on the same client". Theoretical queue replay is still possible on those paths but #1136 specifically traced through the dispatch path, and the legacy socket-close has been battle-tested for the zombie paths since #887. Routing decision is made viaasyncio.get_running_loop()— paho's callback thread has no loop, every legitimate hard-reset caller does. 7 regression tests across two new test classes:TestForceReconnectRouting(3 tests pinning the sync-context → socket-close fallback, async-context → hard-reset path with mock-stubbedconnect(), and the state-disconnected broadcast firing once on either path) andTestHardResetClientDirect(3 tests pinning the helper directly: old client receivesdisconnect()+loop_stop(),_clientreference cleared, failingdisconnect()doesn't propagate so the await chain inbackground_dispatch.pydoesn't break). ExistingTestZombieSessionDetection::test_two_timeouts_force_reconnectandTestDeveloperModeProbeTimeout::test_second_timeout_forces_reconnectupdated to assert the socket-close path (matching their paho-thread context), preserving the legacy contract. All 2179 backend unit tests pass. Thanks to @smandon for the precise reproduction logs that made this diagnosable from a single support package. -
logs/bambuddy.logwas silently dropping records from named child loggers — When the trace-ID column was added to the log format (%(trace_id)s), theTraceIDFilterwas attached to the root logger. Per Python's logging semantics, a filter on aLoggeronly fires for records that originate at that logger — records propagated up from child loggers (everybackend.app.*module — most of the application) never trigger it. Result: child-logger records arrived at the file handler with notrace_idattribute, the formatter raisedKeyError: 'trace_id', andHandler.handleErrorprinted to stderr and dropped the record.bambuddy.logended up with INFO/DEBUG records appearing only "partially" — exactly the records emitted directly throughlogging.info(...)(root logger) oruvicorn.access(which had its own explicit filter attachment) made it; everything else was discarded. Moved_trace_id_filterfromroot_logger.addFilter()toconsole_handler.addFilter()+file_handler.addFilter()— handler-level filters fire for every record the handler receives, regardless of which logger emitted it. The filter's own docstring already said "Attach to the file handler (or any handler whose format string references%(trace_id)s)" — the implementation was just wrong. New regression test intest_trace.py::TestFilterMustBeAttachedToHandlerNotLoggerpins the contract: a child logger emits a record, propagation reaches the handler-level filter, the formatter sees a populatedtrace_idfield, and the line is written. Existing 23 trace tests keep passing unchanged. Restart-shutdown recursion in journalctl was also a side effect — every shutdown log line was raising the formatterValueError, which got caught and logged… raising again, forever, until the lifespan exit unwound; the new placement breaks the cycle since records now format cleanly. -
User-cancelled prints surfaced as "1 problem" on the printer card AND were archived as "Layer shift" failures — Cancelling a print left the printer card stuck on a permanent "1 problem" badge, and stamped the resulting archive entry with
failure_reason="Layer shift"— a fake firmware-fault label in the print history. Affects every Bambu printer that emits a cancel-sequence HMS — the user surfaced it on an H2D where the firmware emits both0300_400C("The task was canceled.") and the not-in-the-public-wiki0C00_001Becho as part of the cancel sequence. Four compounding causes, all fixed together. (1) The direct stop endpoint never set the user-stopped flag.POST /printers/{id}/print/stop(backend/app/api/routes/printers.py) sent the MQTT stop command but didn't callmark_printer_stopped_by_user(), so when the printer reported "failed" via MQTT the on_print_complete override (main.py:2558) couldn't reclassify it as "cancelled". The same flag was being set fromPOST /print-queue/{id}/stop, which is why queue-driven cancels mostly worked but printer-card cancels didn't. The direct endpoint now mirrors the queue path. (2) The HMS → failure_reason heuristic was way too broad. Old code mapped any module 0x0C HMS to "Layer shift" (main.py:3072), but module 0x0C is "Motion Controller" — covers cameras, visual markers, the BirdsEye assembly and the cancel-sequence HMS the firmware emits during a user-cancel. Real layer-shift codes actually live in module 0x03 (0300_4057,0300_4068,0300_800C). The same module-only heuristic was also being used to auto-label "Filament runout" (any 0x07) and "Clogged nozzle" (any 0x05), so the same false-positive class existed on those branches. Replaced the broad module heuristic with a curated short-code → reason map (_HMS_FAILURE_REASONS, 23 specific HMS codes from the real wiki); anything not in that map leavesfailure_reason=Nonerather than guessing. Also extracted the logic into a pure functionderive_failure_reason(status, hms_errors)so it's unit-testable without the full archive pipeline. (3) Cancel-echo HMS codes were pollutingstate.hms_errors. Even with (1) and (2) fixed, the printer card kept showing "1 problem" because the firmware kept reporting0300_400C("The task was canceled.") in subsequent MQTT pushes — andbambu_mqtt._update_statewas happily appending it tostate.hms_errors, where the frontend'sfilterKnownHMSErrorsaccepted it as a valid known code (it IS inERROR_DESCRIPTIONS— just describing a user action, not a fault). Added a parse-time filter (_HMS_USER_ACTION_CODES = {"0300_400C", "0500_400E"}) that drops these short codes before they ever enter the state, mirroring the suppressionmain.py:_HMS_NOTIFICATION_SUPPRESSwas already doing for notifications. The card pip, the "X problem" badge, the modal, and any other consumer ofhms_errorsall get consistent behavior automatically. (4) Frontend countedgcode_state="FAILED"without HMS as a problem. Even with (1)–(3) fixed, the printer card still showed "1 problem" because the H2D'sgcode_statesits atFAILEDafter a cancel until the next print starts, andPrintersPage.tsx:940(header badge) +classifyPrinterStatus(line 1028) +BulkPrinterToolbar.tsx:102all unconditionally bumped theerrorbucket oncase 'FAILED'. Real failures attach an HMS error; user-cancels don't — so FAILED-without-HMS now buckets asfinished(same operator meaning: print ended, plate may need clearing) and only escalates toerrorwhen there's an active known HMS. Same change applied across all three call sites for consistency. 20 regression tests total across three files:test_failure_reason_derivation.py(11 tests pinning the cancel-sequence HMS pair to NOT yield "Layer shift", unknown module-0x0C → None, real layer-shift/runout/clog codes still classify, int-vs-hex code-format tolerance,status="cancelled"symmetric with"aborted"),test_bambu_mqtt.py::TestHMSUserActionFiltering(4 tests pinning0300_400C/0500_400Efiltering on bothhms[]andprint_errorparse paths, real layer-shift0300_4057still passes through, mid-cancel concurrent real-fault keeps the real one and drops only the echo), andPrintersPageBucketing.test.ts(5 tests pinning FAILED-without-HMS → finished, FAILED-with-known-HMS → error, FAILED-with-only-unknown-HMS → finished, FINISH baseline unchanged, disconnected stays offline). Existing stale state on running printers clears on the next MQTT push that includes anhmskey (printer firmware re-sends the list, parser filters it out, badge clears). Users with a stuck badge can also click the HMS modal "Clear" button to clear immediately via MQTT command. -
Settings → API Keys: deleted key stayed on screen until manual reload — the delete-key mutation marked the
['api-keys']query stale viaqueryClient.invalidateQueries, which in v5 should also refetch active queries — but in practice the deleted row remained visible until the user reloaded the page. Switched the mutation'sonSuccesstoqueryClient.setQueryDataso the deleted key is filtered out of the cache synchronously the moment the API confirms; no refetch round-trip required, no chance for an invalidation→refetch race to leave the UI stale. Create-path keepsinvalidateQueriessince that one was working correctly. NewSettingsPage.test.tsxtest "removes a deleted key from the list without a page reload" pins the synchronous-removal contract. -
SpoolBuddy AMS page: re-assigning a just-unassigned spool sometimes showed an empty picker (#1133 follow-up) — Reported live during the rollout of the #1133 picker change: unassigning a Bambu PLA Metal spool from SpoolBuddy and re-opening the picker showed "no spools available" — the just-freed spool was missing. The investigation surfaced four distinct causes that all needed addressing for the picker to stay correct, plus a deployment-side cause that prevented any of the fixes from reaching the live kiosk. (1) Dual cache-key shapes for spool assignments:
SpoolBuddyAmsPagekeys by['spool-assignments', selectedPrinterId]while the sharedAssignSpoolModalkeys by['spool-assignments'], andSpoolBuddyAmsPage.unassignMutation.onSuccessonly invalidated the printerId-keyed one, leaving the modal's unkeyed cache stale. Both invalidate calls (mutation success + modal-close handler) now hit both keys; collapsing the two key shapes into one is intentionally deferred since the dual-key pattern predates this change and shows up in 6 components. (2) Toggle wasn't a real escape hatch: the existing "Show all spools" toggle's label said it would help when a spool was hidden but only bypassed the material/profile filter, not the assignment-elsewhere gate. It now bypasses BOTH filters, making it a real escape hatch (the backend'sassign_spoolis upsert-per-(printer/ams/tray), so picking a currently-taken spool just creates a second assignment row — foot-gun for normal flows but exactly the recovery path this toggle is for). (3) Cross-component cache pollution:['inventory-spools']was used as a query key by 5+ components callinggetSpools()with differentincludeArchivedarguments — React Query treated them as one query and served whichever response landed first, so a SpoolBuddy component priming the cache withgetSpools(false)could hide spools from the modal that wasn't yet present at that fetch time. The modal now uses its own dedicated key['inventory-spools', 'assign-modal']+getSpools(true)so it's never at the mercy of someone else's cache state. (4) Empty-state had no diagnostic surface: when the picker showed "No spools available" there was no way to tell why — was the fetch empty? Were spools archived? All assigned elsewhere? A small counterX fetched · Y archived · Z assigned to other slotsnow renders in the empty state so future reports of this kind are immediately answerable from a screenshot rather than requiring devtools digging. (5) Browser holding stale JS forever:index.htmlwas being served withoutCache-Controlheaders, so Chromium's heuristic-cache freshness window kept the OLD HTML "fresh" for days across browser restarts. The OLD HTML referenced an OLD content-hashed bundle, which was also still in disk cache, so the kiosk kept running pre-deploy JS no matter how many times its Chromium was restarted or cache-cleared — the persistent profile would re-seed the cache from disk on next start. Backend now sendsCache-Control: no-cache, must-revalidateon both/and the SPA catch-all that serveindex.html; service workerCACHE_NAMEbumped frombambuddy-v25tobambuddy-v26so any client that does eventually re-fetchsw.jsinvalidates its CacheStorage; andspoolbuddy/install/install.shnow generates the kiosk launcher with--user-data-dir=/tmp/spoolbuddy-kiosk-userdataplus a pre-launchrm -rfso every kiosk restart starts from a clean slate (the kiosk has no per-user state worth persisting — auth token is in the URL query, not a stored cookie). 6 net-new tests acrossAssignSpoolModal.test.tsx(toggle escape-hatch behavior) andtests/integration/test_static_html_cache_headers.py(Cache-Control directive on root + SPA catch-all routes, no leak onto API routes). Reproduced end-to-end on an H2D + dual AMS + SpoolBuddy display: unassign Bambu PLA Metal Iridium Gold Metallic from slot B4 → reopen picker → spool now visible without browser intervention. -
Plate-clear button stayed visible after the API cleared
awaiting_plate_clearoutside the printer-card click path (#1128) —awaiting_plate_clearis a Bambuddy-side flag, not a printer-side one, so toggling it does not produce an MQTT push from the printer. Commit4e86e8cadded the flag to theprinter_statuspayload so MQTT-driven broadcasts (e.g. when a print finishes and on_print_complete sets the flag to True alongside a state transition to FINISH) carry it correctly. The reverse transition didn't get the same treatment:POST /printers/{id}/clear-platemutatedPrinterManager._awaiting_plate_clearand persisted to the DB, but emitted noprinter_statusWebSocket update — and the in-main.pystatus-change broadcaster'sstatus_keydeduplication intentionally excludes Bambuddy-side flags, so even a coincidentally-arriving MQTT push wouldn't reflect the change. The "Mark plate as cleared" button on the printer card disappeared "immediately" after a click only because the React Query cache was being optimistically updated client-side; clearing the flag through any other route (an admin script, a second tab, an automation hitting the endpoint directly, the scheduler atprint_scheduler.py:1844when dispatching the next queued print) silently left every UI subscriber but the originating tab stale until a coincidental status refresh. Centralised the broadcast inPrinterManager.set_awaiting_plate_clearitself rather than at each call site, so every current AND future caller is covered without remembering to wire it up: a new_broadcast_status_change(printer_id)private coroutine is scheduled alongside the existing_persist_awaiting_plate_clearwhenever the flag flips under a running event loop. The broadcast lazy-importsws_managerto keepprinter_manager.pyclean of application-layer infra at module-import time, short-circuits whenget_statusreturnsNone(printer disconnected — the next reconnect produces a fresh push anyway), and swallowsws_manager.send_printer_statusfailures so the persistence path can complete even if the WS layer is temporarily unavailable. The same hook is now in place for any other Bambuddy-side flag that gets added toprinter_state_to_dictlater — they'll all need to broadcast their own changes for the same reason. 8 new regression tests intest_printer_manager_status_broadcast.py: schedules-on-True/False/loop-running/no-loop/loop-stopped contracts,_broadcast_status_changehappy path with payload assertion, skip-when-no-state, swallow-WS-errors, and an end-to-end live-loop test that firesset_awaiting_plate_clear(False)and asserts a broadcast lands withawaiting_plate_clear: falsein the payload. Existing 24 tests intest_scheduler_clear_plate.pycontinue to pass unchanged because they instantiatePrinterManager()without attaching a loop (sync unit-test path) — the new_schedule_asynccall short-circuits on the same loop check the existing persistence call already used. Thanks to @EdwardChamberlain for the precise root-cause analysis (down to the exact line and the suggestedws_manager.send_printer_status()fix). -
Uvicorn HTTP access log was missing from
bambuddy.log, leaving rogue server-state changes untraceable — When an HTTP endpoint that mutates server state fires unexpectedly (the canonical example: a print spontaneously stopping mid-job because something hitPOST /printers/{id}/print/stop), the only on-disk trail was Bambuddy's own application log — which by design only records the outbound MQTT publish (Sent stop print command), not the inbound HTTP call that triggered it. The result was an unsolvable mystery on 2026-04-26: prints stopping with no preceding Bambuddy-side log line, no way to identify the caller, and the rotated container stdout already gone by the time the support pack was generated. Root cause: uvicorn ships itsaccesslogger withpropagate=Falseby default, so the existingRotatingFileHandlerattached to root never received those records.main.pynow attaches the same file handler directly tologging.getLogger("uvicorn.access")and applies a newWriteRequestsOnlyFilter(backend/app/core/logging_filters.py) that keepsPOST/PUT/PATCH/DELETEand dropsGET/HEAD/OPTIONS. Status polls, camera streams, snapshot fetches, websocket upgrades, and CORS preflights account for the bulk of access traffic on a running install and none of them can change server state on their own — dropping them keepsbambuddy.logfocused on lines that matter for incident triage without churning the 5 MB rotation window faster than it's useful. Filter anchors on the"+verb+pattern uvicorn's format string guarantees, so a literal"POST"substring inside a URL (e.g.GET /api/posts/POST_123) cannot false-match. The filter lives in its own module so the test suite can import it without pulling inmain.py's entire startup graph. 13 new tests intest_logging_filters.pycover all four write verbs being kept, GET/HEAD/OPTIONS being dropped, two URL-contains-verb-substring false-match guards, empty/unrelated-line/idempotency edge cases. Output now looks like2026-04-26 09:23:14,690 INFO [uvicorn.access] 192.168.1.42:54812 - "POST /api/v1/printers/1/print/stop HTTP/1.1" 200— onegrep "POST.*stop"away from "who triggered this". -
Spool auto-assign hit
IntegrityErroron Postgres when AMS pushes arrived in quick succession — Bambu MQTT can deliver twoams_datapush frames for the same printer ~30 ms apart (observed on H2D + dual AMS at K-profile-load / RFID-read boundaries). Each frame triggerson_ams_changeinbackend/app/main.py, whose auto-assign block reads(printer_id, ams_id, tray_id), decides "no existing assignment", and INSERTs viaauto_assign_spool— and the two callbacks raced in their respective sessions, both deciding to insert, with the second commit losing onspool_assignment_printer_id_ams_id_tray_id_key. SQLite's WAL serial-write semantics had been silently swallowing the race for ~7 weeks since the spool-assignment feature shipped (latent inec82092b); when optional Postgres support landed in610431d6and asyncpg started allowing true concurrent transactions, it surfaced asWARNING [main] RFID spool auto-assign failed: ... duplicate key value violates unique constraint ...; DETAIL: Key (printer_id, ams_id, tray_id)=(1, 0, 0) already exists. Added a per-printerasyncio.Lock(_ams_assignment_lockskeyed byprinter_id) wrapping the auto-assign critical section so two callbacks for the same printer serialise — by the time the second one's session runsselect(SpoolAssignment).where(...), the first's commit is visible and the early-return "existing assignment" branch fires instead of a duplicate INSERT. The Spoolman sync block further down in the same callback intentionally stays OUTSIDE the lock — it's network-bound and idempotent, so serialising it would block subsequent AMS callbacks for the duration of a remote roundtrip. Per-printer scope keeps unrelated printers fully parallel: one printer's slow assignment never blocks another's. The auto-unlink block above the assign block isn't wrapped because its DELETE/UPDATE operations don't have the same constraint surface; the assign-block lock is sufficient because the second callback'sselectwill see the first's committed state. 5 new regression tests intest_ams_assignment_lock.pycover same-printer-same-lock identity, different-printers-different-lock isolation, second acquirer waits for first inside the lock (proves serialisation), different printers run truly in parallel under a held lock (proves per-printer scope), and an auto-cleanup fixture resets the module-level dict between tests so cross-test loop affinity bugs can't surface. -
Camera TLS proxy logged "Unhandled exception in client_connected_cb" when ffmpeg dropped its half of the connection mid-stream under uvloop — The bidirectional forwarders inside
services/camera.py::create_tls_proxy._handle(the OpenSSL TLS shim added in #661 so Bambu's RTSPS handshake works around Debian GnuTLS hardening) caught(ConnectionError, OSError, asyncio.CancelledError)on writes, but uvloop'sUVStream.writeraises a plainRuntimeErrorfromUVHandle._ensure_alivewhen the underlying handle is already closed. asyncio's default selector loop reports the same situation asConnectionResetError, so the bug only surfaced on uvloop deployments — and only at the moment the client (typically ffmpeg or a snapshot-capture subprocess) tore down its socket while the proxy was mid-flush. TheRuntimeErrorslipped past the except tuple, escaped the forwarder coroutine, and asyncio'sclient_connected_cbtask-exception handler logged a noisy multi-line traceback ending inRuntimeError: unable to perform operation on <TCPTransport closed=True ...>; the handler is closed. AddedRuntimeErrorto the except tuple in both_fwd_to_serverand_fwd_to_client(the latter being the actual frame in the bug report — server→client is where buffered TLS chunks land after the client has gone). The forwarders are intentionally fire-and-forget on tear-down; once either peer drops, both halves of the proxy should exit quietly and the existingdst.close()in thefinallyblock already handles cleanup. No functional regression possible — the connection is already dead by the time the exception fires; this only changes whether asyncio logs an "Unhandled exception" trace for it. 2 new regression contract tests intest_camera_tls_proxy.pyuseinspect.getsourceto assert both forwarder closures' except clauses includeRuntimeError, since the closures are nested inside_handleand extracting them just for testability would require a pure-cosmetic refactor of the proxy. -
Background-dispatch reported "Print started successfully" when the printer never actually transitioned (#1134, follow-up to #1042) — The int32
task_idmodulo fix that was the original root cause of #1042 is verified working in the reporter's most recent support pack (the publishedtask_idvalues are well below 2^31-1 and match theint(time.time() * 1000) % 2_147_483_647formula exactly). The remaining residual — "the UI reports despatch success which is slightly misleading" — was a real second bug class: the post-dispatch watchdog_verify_print_responseinservices/background_dispatch.pywas fire-and-forget. It would correctly detect that the printer never transitioned (e.g. P1S sitting ingcode_state: FAILEDwith HMS0300_400C"task was canceled", a half-broken MQTT session, an SD card error, or any other pre-print blocker), log adid not respond to print command within 15swarning, force-reconnect the MQTT session — and then return without touching the dispatch job state. The dispatch job had already been marked successful on the optimistic MQTT-publish-acknowledged path, so the UI carried on showing "Print started successfully" while the printer sat idle. The watchdog now returns abooland is awaited inline by both call sites (_run_reprint_archiveat line 687,_run_print_library_fileat line 860); onFalse(timeout) the call sites raise aRuntimeErrorcarrying a user-actionable message ("Printer did not acknowledge print command — state still {pre_state}. Check the printer for a pending error (HMS code, plate-clear prompt, SD card) and try again."), which routes through the existing_mark_job_finished(failed=True, …)path so the dispatch UI shows a real failure toast and the library-file flow's freshly-created archive isdb.rollback()'d (no orphan rows for prints that never started). The watchdog now also acceptssubtask_idadvancing past the capturedpre_subtask_idas a definitive "command landed" signal — same as the queue-side watchdog atprint_scheduler.py:1992(#1078) — so slow H2DFINISH→PREPAREtransitions (~50 s observed) don't false-fail when the printer has clearly accepted the project_file but is still in FINISH. Default timeout raised from 15 s to 90 s to match the queue-side watchdog (#967 / #1078) and give the same headroom on both dispatch paths. Brief mid-window MQTT disconnects (get_status() is Nonefor one tick) now keep polling instead of immediately failing — matches what the queue watchdog already does and avoids false-failing on transient telemetry gaps. The existingforce_reconnect_stale_sessionrecovery is preserved on the timeout path. 8 new regression tests intest_background_dispatch_watchdog.pycover state-change pickup, subtask_id-change pickup with state still FINISH (the H2D case), neither-signal-changed timeout + force-reconnect, pre_subtask_id=None backwards-compat, post-dispatch subtask_id=None not counting as a change (avoids false-pass on transient reconnect), brief disconnect not short-circuiting the window, persistent disconnect for the full window returning False, and a contract test that the default timeout is 90 s. Thanks to @EdwardChamberlain for the detailed retest with logs that pinpointed the watchdog's no-propagation gap. -
Bambu RFID auto-match created duplicate inventory rows for Quick-Add and non-Bambu-branded spools (#918) —
find_matching_untagged_spoolis supposed to attach a Bambu RFID UID to a pre-existing manually-logged spool of the same material/color so users who log inventory before scanning don't end up with a duplicate row on first AMS read. Two bugs in the matcher meant it almost never worked for the actual reporting workflow: (1) the subtype filter was strict — when the AMS tray reportstray_sub_brands="PLA Basic"the matcher requiredSpool.subtype = 'Basic'exactly, so any Quick-Add row (Quick-Add only requiresmaterial, leavingsubtype=NULL) was excluded and duplicated on first AMS read. (2) the docstring claimed it filtered on brand but the WHERE clause didn't, so a same-color Polymaker untagged spool would silently acquire a Bambu Lab tray UUID, leaving the user withbrand="Polymaker"but a Bambu UUID — silent data corruption. Both bugs are addressed in the same query: subtype now prefers an exact match but accepts a NULL-subtype row as fallback (with aCASEinORDER BYso an exact match still wins when both exist), and brand is now restricted to "contains 'bambu' (case-insensitive)" or NULL — matching'Bambu'(the form'sDEFAULT_BRANDSvalue),'Bambu Lab'(the catalog value),'BambuLab','bambu lab', etc., while rejecting any explicitly-named third-party brand. 6 new regression tests intest_spool_tag_matcher.pycover the NULL-subtype fallback, exact-subtype-wins-over-NULL ordering, non-Bambu brand rejection, NULL brand acceptance, all four Bambu brand spelling variants, and the full Quick-Add scenario (brand=NULL+subtype=NULL). The broader UI proposals in #918 (manual override / merge / disambiguation prompt) are intentionally out of scope — once the matcher works, the duplicate-on-RFID complaint that motivated those proposals goes away. Thanks to @ViridityCorn for the report and pointing at the right function, and to @Arn0uDz for confirming with a 20-spool repro. -
Swagger UI link in Settings → API Keys rendered a blank page — the global CSP applied by
security_headers_middlewaresetscript-src 'self'andstyle-src 'self' 'unsafe-inline' https://fonts.googleapis.com, which blocked both the inline<script>that boots Swagger and thecdn.jsdelivr.netURL that shipsswagger-ui-bundle.js/swagger-ui.css. FastAPI's/docspage therefore loaded a 1 KB shell with no JS executed, leaving an empty white page. The middleware now emits a docs-scoped CSP for/docs,/redoc, and/docs/oauth2-redirectthat allowshttps://cdn.jsdelivr.netfor scripts + styles, the FastAPI/Redoc favicon hosts for images, and'unsafe-inline'for the Swagger boot script — every other route keeps the unchanged stricter SPA policy. -
Camera stream second viewer fails / kicks the first off (#1089) — Most Bambu Lab printers only allow one concurrent camera connection (RTSP socket on X1/H2/P2, port-6000 chamber-image socket on A1/P1), but
GET /printers/{id}/camera/streamopened a fresh upstream per viewer keyed on a per-requeststream_id. Two browser tabs / two dashboard cards → the second viewer either failed silently or kicked the first one off. Newservices/camera_fanout.py::MjpegBroadcasterowns a single upstream per printer and fans pre-formatted MJPEG chunks out to N subscriber queues; new viewers tap the existing connection. When the last subscriber leaves, the upstream stays alive for a 5 s grace window so a tab refresh or "open in new tab" doesn't pay an ffmpeg/RTSP reconnect, then tears down cleanly. Per-subscriber queues are bounded (depth 4) so a slow viewer drops frames for itself rather than blocking the broadcaster — live video, old frames have no value. Stop endpoint and app-shutdown both call into the broadcaster's force-shutdown path so subscribers wake up via an upstream-gone sentinel instead of hanging onqueue.get(). External-camera path is unchanged (user-supplied MJPEG/RTSP servers handle multi-viewer themselves). The upstream uses a deterministic{printer_id}-fanoutstream id so every existing prefix-match incleanup_orphaned_streams,camera_status, the snapshot fall-through inmain.py, and thestopendpoint continues to find it without changes. Two follow-up correctness fixes from the audit pass: (1)_stream_start_times[printer_id]is now set withsetdefault()so/camera/statusreports the SHARED upstream's age — previously each new viewer overwrote it, makingstream_uptimejump backward whenever a second viewer attached; (2) the route now retriessubscribe()once onRuntimeErrorto close a tiny race where the grace teardown can flip the broadcaster tostoppedbetween the registry lookup and the subscribe call (the retry forces the registry to mint a fresh broadcaster). Detach log line shows the post-unsubscribe count returned atomically byunsubscribe()— no more two viewers leaving simultaneously both reportingsubscribers=0. Permission gates unchanged:/camera/streamstill requires the existing token (minted byPOST /camera/stream-tokenwithCAMERA_VIEW);/camera/stopstill requiresCAMERA_VIEW; the broadcaster is internal infra with no FastAPI surface. 13 unit tests for the broadcaster (single subscriber, multi-subscriber-shares-one-pump, slow-subscriber-doesn't-block-fast, grace-window teardown, grace-cancelled-on-rejoin, force-shutdown sentinel,iter_subscriberexits on upstream-gone and on client-disconnect, registry replaces stopped broadcasters,subscribe()raises on stopped broadcaster,unsubscribe()returns post-removal count atomically across concurrent leavers, double-unsubscribe is idempotent, and the route's force-shutdown-then-fresh-subscribe retry path) plus 2 new integration tests on the stop endpoint covering the deterministic fan-out stream id and theshutdown_broadcasterwiring. Thanks to @swheettaos for the diagnosis and broadcaster sketch. -
Uploads to writable external folders silently landed in internal storage (#1112) —
LibraryFolderhas anexternal_readonlyflag, so the model already distinguishes writable from read-only external mounts, butPOST /library/filesrejected only the read-only branch and then unconditionally wrote toget_library_files_dir()with a UUID-scoped filename. The resultingLibraryFilerow linked back to the external folder viafolder_id, so the file showed up in the Bambuddy UI and could be printed, but the bytes physically lived inarchive/library/files/and never touched the mount — invisible from any other machine accessing the same NAS/SMB share. New_resolve_upload_destination()helper detects writable external targets and writes through to<external_path>/<filename>(keeping the original filename so the file is recognisable on the mount), with guards for missing/inaccessible path (400), non-writable mount (400), pre-existing filename on the mount (409 — no silent overwrite; the user is expected to rename and retry, matching how scan treats external files as externally-owned bytes), and aresolve + relative_topath-traversal guard on the joined destination. DB row now matches what scan produces:is_external=True,file_path=<absolute external path>, so the existing download / delete / dedupe paths work unchanged (to_absolute_pathalready fast-pathsis_absolute()inputs, and external-file deletion already bypasses trash and only drops the DB row + internal thumbnail).POST /library/files/extract-zipis now rejected against any external folder (not just read-only) with a clear "extract the ZIP on the external mount and run Scan" message — the nested-subfolder creation path would need tomkdiron the mount and create matchingis_external=TrueLibraryFolderrows, which is a separate design round, and the Scan flow already handles that shape. 7 new integration tests cover: bytes land on the mount; DB row hasis_external=True+ absolutefile_path; filename collision → 409 with prior bytes preserved; vanished external path → 400; path-traversal filename never escapes the external dir; extract-zip into writable external rejected with the Scan hint; root uploads unchanged. -
Queue item stuck at "printing" when print failed before reaching RUNNING (#1111) — Dispatching a file sliced for the wrong nozzle size (or any other pre-print error: AMS fault, wrong plate, nozzle not installed, etc.) left the queue item stuck at
status="printing"forever, blocking every subsequent pending item for that printer (check_queueseedsbusy_printersfrom any row in'printing'state and skips further dispatches for those printer IDs). Completion detection inBambuMQTTClient._process_messagerequired the print to have reachedRUNNING— either via_previous_gcode_state == "RUNNING"or the_was_runningfallback — but a nozzle-mismatch failure transitions the printerIDLE → PREPARE → FAILEDwithout ever enteringRUNNING, so neither branch matched andon_print_completenever fired. The diagnostic log line atbambu_mqtt.py:2690("State is FAILED but completion NOT triggered: prev=PREPARE, was_running=False") confirmed the path. Completion now also fires onFAILEDfrom a pre-print state (PREPAREorSLICING) — restricted to those two so a staleFAILEDon first connection (prev=None) still can't accidentally advance an unrelated queue item. Additionally, when a queue item transitions tofailedthe handler inmain.pynow populateserror_messagefrom the printer's current HMS error list, rendered via the existingbackend/app/services/hms_errors.pylookup table (e.g.[0500_4038] The nozzle diameter in sliced file is not consistent with the current nozzle setting. This file can't be printed.) — previouslyerror_messagewas leftNULL, so users saw "failed" with no hint at the cause. 5 new unit tests inTestPrePrintFailureCompletioncover PREPARE→FAILED and SLICING→FAILED firing, IDLE→FAILED and initial-FAILED not firing (boot-time safety), and HMS errors being passed through in the callback payload; 6 new tests intest_hms_error_summary.pycover the error-message formatter (known-code lookup, unknown-code fallback, multi-error join, malformed-entry tolerance, all-malformed → None, empty → None). Thanks to @MartinNYHC for the report. -
Tailscale cert-renewal restart silently failed mid-way (follow-up to #1070) — The daily renewal path creates an
asyncio.Taskto restart VP services with the new cert. Inside that task,stop_server()/stop_proxy()call_cancel_restart_task(), which cancelled+awaited the currently-running task (itself). The self-await raisedRuntimeError, got caught by the broad exception handler, but the cancel flag was still set — so the nextawaitinstop_serverraisedCancelledErrorand aborted the restart partway through. The VP kept running the OLD expired cert until the process was manually restarted, silently defeating the feature._cancel_restart_tasknow checksasyncio.current_task()and skips the cancel+await when the caller IS the restart task itself. Two new regression tests cover the self-cancel and outside-cancel paths. -
Settings table filled with duplicate rows on legacy SQLite installs — pre-UNIQUE-constraint databases stored the
settings.keycolumn without a unique index, so the seed loop'sINSERT OR IGNOREsilently degraded to a plain INSERT and everysystemctl restart bambuddyadded another row ofadvanced_auth_enabled/smtp_auth_enabled. After a handful of restarts,scalar_one_or_none()inis_advanced_auth_enabledand similar sites blew up withMultipleResultsFound, 500'ing the login flow.run_migrationsnow dedupes (keeps MIN(id) per key) and creates the missingix_settings_keyunique index before the seed loop runs. Postgres installs were unaffected. 4 new regression tests cover legacy-with-dupes, legacy-already-clean (idempotent), and fresh-install (no-op) paths. -
Virtual printer card's Tailscale FQDN copy button failed on HTTP —
navigator.clipboard.writeTextis only available in secure contexts (HTTPS / localhost). When Bambuddy is reached over plain HTTP via a LAN or Tailscale IP, the clipboard API is blocked and the copy button silently failed with a generic "Failed to update settings" toast. Added a legacydocument.execCommand('copy')fallback via a hidden textarea for non-secure contexts; the textarea is removed in afinallyblock so it doesn't leak into the DOM on exception paths. NewvirtualPrinter.toast.copyFailedi18n key across all 8 locales for the rare case where both paths fail. -
Install script failed for first-time users — three separate permission issues in
install/install.shstopped the native installer mid-way: (a)download_bambuddychowned the empty install dir to the service user BEFORE runninggit cloneas the current user → permission denied on.git; (b)setup_virtualenvcreated the venv as the service user but then ranpip install --upgrade pipas the current user → permission denied writingvenv/bin/pip; (c)build_frontendwould have hit the same pattern onnpm ci. All three now route throughsudo -u "$SERVICE_USER"(orsudo -H -ufor npm so HOME is set correctly for the npm cache). The git-clone fix runs as root then chowns the tree. macOS path unchanged (no service user there). -
H2C dual-nozzle detection missed post-2026 serial batches (#1105) — Bambu has started shipping H2C units with a new serial prefix (
31B8B…observed on a January 2026 unit) instead of the legacy094…shared by the H2D/H2C/H2S family. The K-profile edit flow (backend/app/api/routes/kprofiles.py) and the delete-K-profile MQTT path (backend/app/services/bambu_mqtt.py::delete_kprofile) branch on serial prefix to pick the dual-nozzle command format, so units with the new prefix were silently falling into the single-nozzle branch and getting the wrong K-profile payload shape. Added31B8B(5-char match covering the model code + revision bytes, leaving the revision-letter slot free to iterate) alongside the existing094and20P9prefixes; runtime paths that auto-detect dual-nozzle fromdevice.extruder.infowere already prefix-agnostic. New regression testtest_h2c_new_prefix_uses_dual_nozzle_formatintest_bambu_mqtt.py. Thanks to @m4rtini2 for the report. -
Spoolman iframe silently blank on HTTPS Bambuddy with HTTP Spoolman (#1096) — Users behind an HTTPS reverse proxy (Traefik / Nginx / Caddy) pointing the Spoolman URL at plain HTTP saw the Filament tab render as a blank page with only a console-side
Mixed Contentwarning. CSP was fine (the#1054fix already allowedframe-src http:), but browsers enforce mixed-content blocking independently of CSP — an HTTP iframe inside an HTTPS parent is always blocked. Bambuddy can't technically fix this (the browser is correct to refuse), so instead of the silent blank frame the Filament page now detects the protocol mismatch (window.location.protocol === 'https:'plus Spoolman URL starting withhttp://) and renders an inline warning card explaining the root cause, pointing users at the right fix (put Spoolman behind the same HTTPS reverse proxy and update the Spoolman URL in Settings), and offering an "Open Spoolman in a new tab" button as an immediate workaround — a standalone tab isn't subject to mixed-content rules. Localised across all 8 UI languages. Thanks to @jsapede for the report. -
Reprint-from-Archive left
created_by_idasNULL(#730 follow-up) — 0.2.4b1 fixed user attribution for Direct Print / File Manager / Library prints, but the reprint path was still unattributed on the archive row. Reprint intentionally reuses the source archive (to avoid duplicate rows — seeregister_expected_print), so an archive auto-created from a printer-initiated print with no known user stayedcreated_by_id=NULLforever, even after multiple reprints by authenticated Bambuddy users. Print Log got the reprinter's username correctly (via_print_user_info), but the Statistics per-user filter — which readsarchive.created_by_id— kept showing the archive as unassigned. Fix inmain.py's print-complete handler: when the archive has nocreated_by_idand a print-session user is set (which reprint always sets viaset_current_print_user), back-fill the archive's attribution. Never overwrites an existing attribution — the original uploader keeps ownership; NULL archives are the only ones touched. Thanks to @3823u44238 for the detailed retest that caught this. -
Settings: failed-save toast looped forever when the user lacked
settings:update— the Settings page runs a debounced auto-save effect that firesPATCH /settingswheneverlocalSettingsdiverges from the last server snapshot. When a delegated user withsettings:readbut notsettings:updatetoggled a control, the effect firedPATCH, got403, and kept re-firing every ~500 ms producing an endless stream of identical "Failed to save" toasts. Gated at three points so the mutation is never attempted without permission: (1) theupdateSettingcallback — every onChange path — shows onesettings.toast.noPermissionUpdatetoast and short-circuits before diverginglocalSettings; (2) the debounced-save effect safety-nets the same check in case any call site bypassedupdateSetting; (3) the language<select>was a fire-and-forget directapi.updateSettingscall that always flashed a success toast regardless of outcome — it now goes throughupdateMutationwith the same permission guard. Newsettings.toast.noPermissionUpdatekey added across all 8 locales with full translations (not English-fallback). -
Groups: edits to custom-group permissions appeared lost on reopen (#1083) — creating a custom group and reopening the editor showed the correct permissions, but after editing that group's permissions and saving, reopening the editor within ~1 minute displayed the pre-edit snapshot as if the save had failed. The backend
PATCH /api/v1/groups/{id}was persisting correctly (now covered by four new integration tests intest_groups_api.py, including a direct DB read after update); the issue was purely in the frontend React Query cache —GroupEditPage.onSuccessinvalidated['groups'](the list) but left the['group', id]detail cache stale, and with the app-wide 60 sstaleTimethe next mount served the cached pre-update body instead of refetching.onSuccessnow primes the['group', id]detail cache with thePATCHresponse body so the next mount hits fresh data immediately without a round-trip. Create-path invalidates['group']for symmetry. Regression test inGroupEditPage.test.tsxverifies the detail cache contains the updated permissions after save. -
Setup: re-enabling auth could 422 on a password the form no longer needs — after disabling authentication and re-enabling it (common when switching between local auth and LDAP, or recovering from a bad config), the setup form still sends
admin_passwordin the body even though the backend route ignores it when an admin user already exists. TheSetupRequestPydantic schema enforced password complexity (uppercase + lowercase + digit + special char) unconditionally, so any existing password that predated the complexity rule — or a legitimate LDAP-mode placeholder — triggered422 Value error, Password must contain at least one special characterbefore the route body could decide to ignore the field. Complexity validation has moved out of the schema and into the route body, scoped to the branch that actually creates a new local admin. Re-enabling auth with an existing admin (or any LDAP user) now accepts whatever the form sends; fresh first-time setup still rejects weak passwords with a clear 400. Two regression tests added intest_auth_api.py: weak password rejected at setup when creating the first admin, weak/placeholder password accepted when an admin already exists. -
Queue: batch (quantity>1) double-dispatched onto the same printer — scheduling an ASAP print with
quantity > 1could end up with two queue items in'printing'status for the same printer, surfaced in the logs asBUG: Multiple queue items in 'printing' status for printer N. The scheduler's in-memorybusy_printersset was seeded empty each tick and only populated after_start_printsucceeded in the current iteration, so on the next tick (30 s later)_is_printer_idle()read the printer's live MQTT state — which on H2D / P1 series lags several seconds behind the print command and still reportedIDLE/FINISH— and dispatched the second batch item onto the already-running printer.check_queue()now queriesPrintQueueItemforstatus='printing'rows and seedsbusy_printerswith their printer IDs before iterating pending items, so any printer with an outstanding dispatched job is excluded regardless of what MQTT currently reports. Regression covered intest_phantom_print_hardening.py(TestBusyPrinterSeedingFromPrintingItems): seeding query returns printers with'printing'rows only, returns empty when none exist, and end-to-endcheck_queue()does not call_start_printfor a pending item whose printer already has a'printing'row even when_is_printer_idle()is forcedTrue. -
Queue: active-item progress bar flashed 100% before dropping to 0% — immediately after a queue item was dispatched, the per-item progress bar on the Queue page showed 100% (or whatever the prior print's final
mc_percentwas) for the few seconds between dispatch and the printer's MQTT state transitioning toRUNNING. FrontendQueuePage.tsxreadstatus.progressdirectly from the printer's live MQTT snapshot, which carries over the last reported value from the previous print until the new one starts ticking. The progress bar, remaining time, ETA, and layer counter are now gated onstatus.statebeingRUNNINGorPAUSE; in any other state (includingFINISHfrom the prior print,IDLE, orPREPAREwhile heating) the bar renders at 0% with no stale ETA/layer values. -
i18n placeholder mismatches in Japanese rendered literal
{{count}}/{{name}}strings in the UI — 27jastrings had drifted from the en placeholder names:printers.activeNozzleused{{side}}while the runtime passesnozzle;archives.card.layers/queue.addedBy/maintenance.days/groups.form.permissionsand 22 others either lost their placeholders entirely (translator dropped them) or used renamed keys ({{count}}→{{username}}). i18next can't bind a placeholder it doesn't see, so the count would silently disappear or — for{{count}}keys — render the raw{{count}}token in the UI. Backfilled all 27 to match en's placeholder set so interpolation resolves; the parity check now reports zero placeholder mismatches across all 8 locales. Same pass also fixed onefrmismatch (projects.noProjectsFilteredHelplost{{status}}). -
"Open in Slicer" fails on Windows / Linux for any filename containing spaces or special characters (#1059) — clicking "Open in Slicer" from the File Manager or Archives page produced one of three symptoms depending on the file:
.3mffiles opened Bambu Studio / OrcaSlicer but the app showed "Importing to Bambu Studio failed. Please download the file and open it manually" (the file on disk was 0 bytes);.stlfiles greyed the button out;.stepcouldn't be previewed at all. The protocol-handler URL emitted byfrontend/src/utils/slicer.tsfor OrcaSlicer (orcaslicer://open?file=<URL>) and Windows/Linux Bambu Studio (bambustudio://open?file=<URL>) was built by plain string concatenation with noencodeURIComponent()— the macOSbambustudioopen://<URL>branch was already encoding correctly, which is why macOS users didn't see this. A stale comment block in the file claimed the browser preserves the URL in the query string so no encoding is needed; that's true for the browser-to-OS handoff but ignores that the slicer itself callsurl_decode()on the received query (BSpost_init()callsurl_decodethensplit_str; OrcaSlicer's Downloader regex-extracts thenurl_decode). Any already-percent-encoded character in the download URL — most commonly%20from filenames with spaces, which Bambuddy's archive paths produce naturally — decoded to a literal space and the slicer's subsequent HTTP GET came back 0 bytes or 404. All three URL forms nowencodeURIComponent()the file URL, so the slicer sees the correctly-encoded URL after its ownurl_decode. The comment block is corrected to document the actual invariant. Regression test inslicer.test.tsfeeds the exact issue reproduction URL (Toothpick%20Launcher%20Print-in-Place.3mf) and asserts%2520appears in the generatedorcaslicer://href — so any future refactor that drops the encoding fails CI. Thanks to @jsapede for the double-encoding diagnosis and @AllanonBrooks and @lunaticds for the original reports.
Security
- postcss bumped to 8.5.12 to clear GHSA-qx2v-qp2m-jg93 — moderate-severity advisory: PostCSS < 8.5.10 has an XSS via an unescaped
</style>sequence in its CSS Stringify output. The caret range infrontend/package.jsonalready accepted 8.5.12, so this is a lockfile-only bump; vite, autoprefixer, and@tailwindcss/postcssall dedupe onto the same 8.5.12 with no nested copies left innode_modules. PostCSS runs at build time only and Bambuddy doesn't pass user-controlled CSS through it at runtime, so the practical impact even on the older version was nil — this is hygiene + clearing thenpm auditwarning.
[0.2.3.2] - 2026-04-22
Improved
- GCode Viewer Reshaped as an Archive Preview Tool (#963 follow-up) — PR #963 landed the embedded PrettyGCode viewer with a library file picker, a connected-printer selector with live WebSocket status, and auto-load of the currently-printing file. In practice those three didn't match Bambuddy's data model: the library file picker only listed
.gcodefiles (Bambuddy stores.gcode.3mf), the printer selector wasn't useful when the real goal is previewing an existing archive, and the auto-load path had the same.gcode-filter gap as the picker. The viewer is now scoped to a single focused workflow — "show me the G-code for this archive" — reached from the Archives page 3D-preview button (menu item + the card-corner badge + list-row menu, all three paths navigate the same way). Entry URL is/gcode-viewer?archive=<id>[&plate=<N>]; the route falls through to the SPA catch-all so a full-page reload keeps the Bambuddy layout shell, with the iframe at/gcode-viewer/?archive=<id>…serving the raw viewer. Bed size is fetched fromGET /archives/{id}/capabilities.build_volume(already parsingprintable_area+printable_heightfrom the 3MF'sMetadata/project_settings.config) so any printer model renders the correct bed — 350×320×325 for H2D etc. — with no hardcoded per-model map to maintain. Multi-plate archives now surface a dedicated plate picker modal (components/PlatePickerModal.tsx) with thumbnails and object lists matching the existing Re-print modal's visual language; source-only 3MFs (no sliced gcode) show aarchives.platePicker.noGcodetoast instead of sending the user to an empty viewer. Behind the scenes:GET /archives/{id}/gcodeaccepts?plate=Nand resolves the filename by integer-matching the suffix (zero-padded names likeMetadata/plate_01.gcodenow resolve as plate 1, fixing a class of picker-claimed-but-404 archives);GET /archives/{id}/platesgained a top-levelhas_gcode: boolflag so the frontend can suppress the picker when the archive is source-only;printer_state_to_dictnow injectsnameandmodelinto every WebSocket snapshot so consumers don't race a separate/printersfetch for proper labels. Removed from the viewer: printer selector + WS subscription, library file picker,BAMBU_BED_SIZEShardcoded map, auto-load-currently-printing, sidebar nav entry, 32 orphanedgcodeViewerlocale keys, and the unreachableModelViewerModalrender paths on archive cards (the File Manager still usesModelViewerModalfor library file previews — scope preserved). Added test coverage:?plate=Nhappy path, zero-padded filename resolution, missing-plate 404, no-plate fallback to first,?plate=0400 rejection,has_gcode=true/falsebranch, plusPlatePickerModal.test.tsx(6 tests covering render, plate-name label, onSelect payload, backdrop close, thumbnail fallback) andprinter_state_to_dictname/model surfacing tests. A toast replaces the old silent empty viewer for source-only archives; reload stays in the Bambuddy layout; H2D previews no longer overflow the bed.
Improved
- Printer Card Shows Plate Name on Multi-Plate Prints (#881) — When two printers were running different plates of the same multi-plate 3MF, the Printers page cards displayed the same file name on both and gave no visual way to tell them apart. The Queue view already showed the plate name by querying the archive's plate list; the Printers page didn't have that linkage. The
GET /printers/{id}/statusendpoint now returnscurrent_archive_id(resolved by matching the MQTTsubtask_idagainstPrintArchive.subtask_id, the same bridge introduced in #972 for restart-resume) andcurrent_plate_id(parsed from the MQTTgcode_filepath by a new sharedparse_plate_idhelper that's also used by the WebSocket push path, so plate transitions within a running print reflect immediately instead of waiting 30 s for the next REST poll). The card fetches plate metadata via the sameapi.getArchivePlates()call the Queue page uses — shared React Query cache keeps it cheap across polls — and renders the actual plate name (or a "Plate N" fallback) only when the source 3MF is multi-plate, so single-plate prints stay noise-free. Falls back to the previousplate_(\d+).gcoderegex when there's no archive linkage (e.g. prints started directly from the printer LCD). Regression tests cover the plate-id extraction across Bambu Studio path shapes and the label-override precedence informatPrintName. Thanks to @stringham for the follow-up and screenshot.
Improved
- Printer Card: Remove Redundant In-Widget "Clear Plate & Start Next" Button — In expanded view, the "Next in queue" widget rendered its own
Clear Plate & Start Nextbutton inside a yellow-bordered card (PrinterQueueWidget.tsx) whenever the plate-clear gate was up and an auto-dispatch item was queued — on top of the card-level "Mark plate as cleared" button introduced by #939. Both POSTed to the exact same/printers/{id}/clear-plateendpoint with identical optimistic-update semantics, so in that one state combination users saw two visually distinct affordances doing the same thing. Removed the widget's button and its entireneedsClearPlaterender branch; the card-level button (which is unconditional when plate-clear is required, and therefore already handles the staged-only and empty-queue cases that the widget couldn't) is now the single entry point. The widget becomes a pure passive "Next in queue" preview linking to/queue. No backend change, no change to the plate-status pill placement inside the Status box (deliberately kept where it is), and no change to compact-view (Size S) behaviour — theplateStatusPillatPrintersPage.tsx:2664/2671and the icon-only round clear-plate button at:2673are untouched. Also dropped the now-deadawaitingPlateClear/requirePlateClear/printerStateprops fromPrinterQueueWidgetPropsand the matching call site atPrintersPage.tsx:2810, and the orphanedqueue.clearPlate/queue.plateReadytranslations from all eight locale files (queue.clearPlateSuccessis retained — still used by the card-level button's success toast). The dedicatedPrinterQueueWidgetClearPlate.test.tsxsuite (654 lines) was removed since every test asserted the behaviour of the now-gone button;PrinterQueueWidget.test.tsxcontinues to cover the passive-link path. Thanks to @EdwardChamberlain for flagging the duplication in #1079.
Fixed
- Print Scheduler Reprints the Just-Finished Job When Queue Has One Item Left (H2D) (#1078) — On H2D, clearing the plate and starting the next (and only) queued item caused the printer to re-run the job it had just finished while the UI reported the queued one as started. With multiple items left the symptom was hidden by forward progress. Root cause:
_watchdog_print_startinprint_scheduler.pygives up at 45 s and reverts the queue item topendingifgcode_statehasn't flipped away frompre_state, on the assumption that a non-transitioning printer means the MQTTproject_filepublish was swallowed by a half-broken session (#887/#967). H2D Pro firmware (01.01.00.00) routinely keepsgcode_state=FINISHfor 48–55 s after actually accepting the command before transitioning toPREPARE— logs from the reporter show the revert firing at +45 s and a legitimatePRINT START detectedarriving just ~3 s later — so the watchdog reverted an item that the printer had already started physically printing. The physical print ran to completion and updated the linked archive (viaregister_expected_print), but the queue item was nowpendingagain; on the next scheduler tick after the user cleared the plate, the same item was re-dispatched as if it had never run. With multiple items queued, item N+1 getting dispatched during the 45 s race window looked like forward progress to the user and masked the duplicate revert/re-dispatch of item N. Fixed in_watchdog_print_startby adding a second "command landed" signal:subtask_idchanging past the pre-dispatch value. Bambuddy already mints a uniquesubmission_idperproject_filepublish (capped at int32 post-#1042) and assigns it tosubtask_id/task_idin the command payload; the printer echoes this back on the nextpush_statusas soon as it starts processing — well beforegcode_statetransitions on slow-transition models._start_printnow capturespre_subtask_idalongsidepre_stateand passes both to the watchdog, which treats either a state change or asubtask_idadvance as proof the command landed. Timeout raised 45 s → 90 s as belt-and-braces for printers that neither transition state nor echosubtask_idinside the polling window. None of the earlier exit paths are weakened — genuine half-broken sessions (state andsubtask_idboth unchanged across the full window) still revert, still force the MQTT reconnect, and are still recoverable without a power cycle. Added eight regression tests intest_scheduler_watchdog.pycovering: pickup via state change, pickup viasubtask_idchange while state stays atFINISH(the exact #1078 case), revert when neither signal changes, default timeout of 90 s,pre_subtask_id=Nonefallback to state-only,status.subtask_id=Nonenot mis-detected as a change, printer disconnect mid-watchdog (no DB write), and the#967race where the item already moved on (completed). No frontend or MQTT changes — purely tightens the "did the printer accept?" decision. Thanks to @VREmma for the clear reproduction and the full support bundle that made pinpointing the H2D state-lag behaviour possible. - Printers-Page "Clear Plate" Button Takes 30–300+ s to Appear After Print Completes (#939 follow-up) — A trusted user reported that on every printer (A1, H2D, X1C), the "Clear Plate & Start Next" button didn't show for 60+ seconds after a print finished; refreshing didn't help; one H2D sat in the "Finished" state for 5 minutes without the button ever appearing. Root cause: PR #939 added the
awaiting_plate_cleargate but stored it onPrinterManager._awaiting_plate_clear(a per-process set, persisted toprinters.awaiting_plate_clearvia #961), not onPrinterState— andprinter_state_to_dict()inprinter_manager.py, which builds every WebSocketprinter_statuspayload, was never updated to emit it. Only the HTTP endpointGET /printers/{id}/status(line 634) surfaced the flag. That left the frontend in a deadlock: whenprint_completearrived over the WebSocket,useWebSocket.tsintentionally didn't invalidate['printerStatus'](avoiding the render-cascade freeze the comment at line 235 warns about), expecting the subsequentprinter_statusWS messages to "naturally update the status" — but those messages carried noawaiting_plate_clearfield, so the merge at line 146 preserved the stalefalse. The only path that ever surfacedtruewas the 30 s HTTP fallback poll atPrintersPage.tsx:1430, and on a chatty printer each incoming WS tick'ssetQueryDatabumped React Query'sdataUpdatedAt, pushing the next fetch further out — which is why the delay varied from ~30 s to several minutes. The plate-status pill atPrintersPage.tsx:1672-1675rendered "Plate Clear" (the fallback label for falsyawaiting_plate_clear) during the entire stale window, compounding the confusion. Fixed by emittingawaiting_plate_clearfromprinter_state_to_dict: the function already hasprinter_id, so it readsprinter_manager.is_awaiting_plate_clear(printer_id)directly and returnsFalsewhen no id is passed (for the few callsites that don't have one). No frontend change needed — the existing WS merge path now carries the flag end-to-end, the "Clear Plate" button appears instantly on completion, and the queue-dispatch side of the gate (which already reads the in-memory set directly viaprint_scheduler.py:1125) is unaffected. Regression tests intest_printer_manager.pyassert the WS dict always contains the key and that it surfacesTruewhen the manager has the flag set for that printer_id. Affects every printer equally because the path is transport-agnostic — not an H2D- or A1-specific problem, just more visible on H2D because its longer finish sequence gave the poll slip more opportunities to miss. - Printers-Page Search Turns Into a Password Field After Opening Change-Password Modal — On the Printers page, clicking the key icon in the sidebar to open the Change Password modal caused the "Search printers" input to render as a password field (masked dots); closing the modal didn't restore it, requiring a full reload. Root cause: the Change Password modal has three
<input type="password">fields but no accompanying username input, so password-manager browser extensions (1Password, Bitwarden, Chrome/Safari built-in) scanned the current DOM for a matching username anchor and latched onto the nearesttype="text"input with noname/autoComplete— which happened to be the Printers-page search bar — and overrode its rendering. Fixed on two levels: (1) added a hidden<input type="text" name="username" autoComplete="username" value={user.username} readOnly hidden>at the top of the Change Password modal so password managers have a proper anchor and stop hunting elsewhere — as a bonus, saved new passwords are now correctly keyed to the logged-in user; (2) hardened the Printers-page search input withtype="search",name="printer-search",autoComplete="off", anddata-1p-ignore/data-lpignore="true"so any future heuristic-based autofill also skips it. - AMS Slot Configure: Custom Cloud Preset Resolves to "Generic" in Slicer & Printer LCD (#1053 follow-up) — After configuring any AMS slot (HT or regular) with a user custom Bambu Cloud preset built on top of a Bambu base profile (e.g. "Sting3D ABS" inheriting from "Generic ABS @BBL H2D"), OrcaSlicer's Sync Filaments continued to resolve the slot to "Generic ABS" and the custom preset never appeared on the printer's own LCD — independent of the earlier UI fix (commit
87a5aa36) which only corrected Bambuddy's own modal. Root cause: when Bambu Cloud'sGET /cloud/settings/{setting_id}returns a user preset withfilament_id: nullandbase_id: "GFSB99_07"(cloud doesn't mint a distinct filament_id for presets that only override fields of a generic base),ConfigureAmsSlotModal.tsx:382-384fell back toconvertToTrayInfoIdx(base_id)which strips the version suffix and theSprefix →"GFB99"— Generic ABS's filament_id. The printer accepted and reported backGFB99, so both the LCD and OrcaSlicer correctly resolved the slot to Generic ABS. The fallback was never right: the preceding default already settray_info_idx = convertToTrayInfoIdx(selectedPresetId)which for anyPFUS*/PFSP*setting_id returns the base setting_id itself (via the helper'sstartsWith('PFUS')branch added earlier), and the printer + both slicers round-trip that format unchanged — confirmed by existing backend integration tests (test_configure_pfus_sent_directly,test_pfus_slicer_filament_used_directly), by the print scheduler's slot-matching which already expectsP*short-form IDs in the printer's reportedtray_info_idx(print_scheduler.py:910), and by the inventory Assign Spool flow which has been sendingPFUS*preset IDs to the printer for months. The buggy fallback overwrote the correct default with a generic mapping. Fixed by removing the base_id branch: when cloud detail carries a distinctfilament_idwe still prefer it, otherwise we keep the setting_id-derived default. BambuStudio Sync now resolves the custom preset cleanly; OrcaSlicer (whose user presets don't carry afilament_idfield at all, onlyinherits) will continue to fall back to the inherited generic — that's an OrcaSlicer preset-format limitation, not something Bambuddy can fix on its side, and the behaviour is strictly not worse than before. Regression tests inConfigureAmsSlotModal.test.tsxpin four paths: (1) cloud detail withfilament_id: null→tray_info_idxis thePFUS*setting_id, (2) cloud detail with a concretefilament_id→ that filament_id wins over the default, (3) GFS* Bambu presets skip the cloud-detail fetch entirely and still map to the shortGF*filament_id, and (4) a 5xx / network error on the cloud-detail fetch degrades gracefully to thePFUS*default instead of aborting the configure flow. An end-to-end backend test (test_configure_pfus_preserves_setting_id_pair) locks in that bothtray_info_idx=PFUS…andsetting_id=PFUS…survive the HT-slotPOST /slots/{ams}/{tray}/configurepath untouched. Thanks to @mrnoisytiger for the detailed browser-console / network / backend-log diagnostic data that isolated the fallback path, and for sharing the OrcaSlicer preset JSON that showed the missingfilament_idfield. - Single Malformed
rgbaBricks the Entire Filaments Inventory Page (#1055) — A user's Filaments page went blank and "Add Spool" became a no-op with no visible error. The backend was returning HTTP 500 fromGET /api/v1/inventory/spoolswithfastapi.exceptions.ResponseValidationError: rgba → 'FFFFFFF' should match pattern '^[0-9A-Fa-f]{8}$'— a single legacy spool row had a 7-char rgba (missing one trailingF) and Pydantic's strict pattern onSpoolResponserefused to serialize the whole list because of it. Root cause spans three layers: (1)SpoolUpdatehad no rgba pattern constraint, so PATCH calls could plant malformed values straight into the DB (SpoolCreatedid validate, but only on initial create); (2) theColorSectionhex input's onChange ternaryval.length <= 6 ? 'FF' : ''silently emitted 7-char strings for 5-char or 7-char typed input (5 chars +FFalpha = 7 chars; 7 chars got no alpha appended at all), which then flowed to the unvalidated PATCH endpoint; (3)SpoolResponseinherited the same pattern asSpoolCreate, so any malformed row already in the DB exploded the entire list endpoint on serialize even though write-side validation was the right place for the check. Fixed on all three layers:SpoolUpdate.rgbanow carries the same^[0-9A-Fa-f]{8}$pattern asSpoolCreate, so PATCH requests with malformed rgba are rejected with 422 at the boundary. The hex input always emits a fully-formed 8-char RRGGBBAA on every keystroke — 8-char paste passes through, 7-char drops the stray char, shorter input is right-padded with'0'and given FF alpha.SpoolResponse.rgbais now an unconstrainedOptional[str]: the pattern belongs on request schemas where Pydantic can reject bad input, not on responses where it turns a single bad row into a total page failure. A legacy malformed row still appears in the UI (the color just renders as whatever browser default applies) but the user can see, edit, and delete it instead of having to hand-edit SQLite. Backend tests cover all three schema contracts (16 cases acrossSpoolCreateaccept/reject,SpoolUpdateaccept/reject,SpoolResponselenient-tolerance on 7-char / null / garbage). Frontend tests cover the hex-input normalization for every input length 0–8 plus non-hex strip-and-pad. Thanks to @fdsghy4a for the end-to-end debugging and for locating the exact malformed row in their DB. - Printer-Card "Print" Button Leaves Transient Copy in File Manager (#730) — The "Print" button on a printer card (and the equivalent drag-drop-onto-card flow) was silently uploading the chosen file into the Library file manager as a side effect before printing. Root cause is structural: the frontend opened
FileUploadModalto persist the file as aLibraryFile, thenPrintModaldispatched a library print throughPOST /library/files/{id}/print, which uses the LibraryFile as the source for both the archive copy and the FTP upload to the printer. When the dispatch finished, both theLibraryFilerow and its disk file indata/library/were left behind, so every one-off Direct-Print accumulated an unwanted File Manager entry that the user had to find and delete manually. The other three print entry points are untouched: Archive "Reprint" never involved the library, and File Manager "Print" / Project Detail "Print" are paths where the user deliberately put the file in the library, so their entries are preserved.POST /library/files/{id}/printnow accepts an optionalcleanup_library_after_dispatchboolean. When true,_run_print_library_filestages the LibraryFile row for deletion in the same transaction as the archive insert (so a mid-flight FTP orstart_printfailure rolls back both at once, leaving no orphan), commits together, then unlinks the library disk file and thumbnail from disk after commit succeeds. External library files (is_external = True, pointing at user-managed folders outside Bambuddy's control) are never touched regardless of the flag. The Printers-page Direct-Print flow is the only caller that sendstrue; every otherapi.printLibraryFilecall site leaves the flag unset so default-False preserves their library entries. Added two unit tests at the enqueue level (default-false + flag-propagates-true), two integration tests at the endpoint level (default-false + forwards-true + cleanup flag never leaks into the MQTT options dict), and two frontend tests onPrintModalguarding thatcleanupLibraryAfterDispatchonly forwards when explicitly set — so future File Manager / Project Detail entry points can't accidentally inherit the Direct-Print semantics. Thanks to @3823u44238 for flagging the surprising side effect. - Direct / File Manager / Library Prints Still Unattributed to User (#730) — The 0.2.3.1 fix (commit
f03d0c4c) plumbed the authenticated user fromPOST /library/files/{id}/printinto the background-dispatch job object, but the dispatcher itself never read it back out:_run_print_library_filecalledArchiveService.archive_print()without thecreated_by_idparameter and never calledprinter_manager.set_current_print_user(). Net effect: direct prints from the printer-card "Print" button, File Manager prints, and Library prints all continued to land archives withcreated_by_id = NULL(invisible to the per-user stats filter), and the post-print email notification had no user to target. The dispatcher now forwardsjob.requested_by_user_idto the archive at creation time and registers the current-print user afterstart_printsucceeds — matching the reprint path's behaviour. Reprint-from-Archive attribution is a separate bug (the reprint reuses the source archive row as-is, so a NULLcreated_by_idstays NULL) and is tracked on #730. Thanks to @3823u44238 for the thorough end-to-end retest. - Spoolman Iframe Blocked by CSP on HTTP Instances (#1054) — The Filament tab showed a blank page with a brief Spoolman flash on reload. Browser console reported
Content-Security-Policy: The page's settings blocked the loading of a resource (frame-src) at http://<host>:7912/spool because it violates the following directive: "frame-src 'self' https:". Root cause: commit53a70e37(#995) tightened the CSP to allow external sidebar iframes but only whitelistedhttps:, overlooking that self-hosted services on LANs — Spoolman, OctoPrint, etc. — almost always run over plain HTTP. Theframe-srcdirective now allowshttp:as well (frame-src 'self' http: https:), matching theconnect-src 'self' ws: wss:pattern already used for WebSockets.frame-ancestors 'none'still prevents Bambuddy itself from being framed cross-origin. Thanks to @saint-hh for reporting. - AMS-HT: Custom Filament Preset Reverts to "Generic" in UI After Configure (#1053) — After configuring an AMS-HT slot (HT-A/HT-B) with a custom Bambu Cloud preset (e.g. "Devil Design PLA Basic"), the slot card and Configure modal kept showing "Generic PLA" even though the
ams_filament_settingcommand succeeded and BambuStudio / the printer's LCD both rendered the correct custom preset. Root cause: theGET /api/v1/printers/{id}/slot-presetsendpoint keyed its response dict byams_id * 4 + tray_id, which collapses cleanly to the same integer the frontend uses for regular AMS slots (0 through 15) but produces128 * 4 + 0 = 512for HT-A — a key nothing looks up. The frontend's PrintersPage HT render path callsgetGlobalTrayId(ams.id, …, false)which returns the ams_id itself (128for HT-A), and SpoolBuddy's AMS page used a third, unrelated formula ((amsId - 128) * 4 + trayId + 64 = 64). All three agreed for regular AMS so the mismatch only surfaced on HT, where the saved preset name never reached the UI and the render fell through totray.tray_type→ rendered as "Generic PLA". Backend now keys the response via a_slot_preset_keyhelper that mirrors frontendgetGlobalTrayId(HT →ams_id, regular/external →ams_id * 4 + tray_id), and SpoolBuddyAmsPage uses the sharedgetGlobalTrayIdhelper instead of its home-grown formula. Regression test covers the key scheme for regular, HT, and external slots. Thanks to @mrnoisytiger for the detailed reproduction. - ⚠️ Bed-Jog "Home Z" Could Crash the Bed Into the Toolhead (#1052) — Critical safety fix. On H2C (and by extension any Bambu printer where Z-home moves the bed UP toward an endstop — H2D, H2S, and X1 family all share this kinematics) the bed-jog modal's "Home Z" button sent a raw
G28 Zover thegcode_lineMQTT command. BareG28 Zskips the toolhead-park step that a fullG28runs first, so the bed raised without stopping at a safe height — in the reporter's case the toolhead happened to be parked on the purge chute and no damage was caused, but hitting the button with a toolhead anywhere else would have driven the bed into it at full Z speed. Root cause was the/api/v1/printers/{id}/home-axesendpoint's per-axis gcode mapping ("z" → "G28 Z","xy" → "G28 X Y","all" → "G28"). The endpoint now ignores theaxesargument entirely and always sends a bareG28, which Bambu firmware expands into the safe multi-step sequence (park toolhead → home XY → home Z). The MQTT client helperBambuClient.home_axes()has the same change. The bed-jog modal is retitled "Auto Home" and its copy now says "parks the toolhead, then homes X, Y, and Z" so users aren't surprised when X/Y motion happens first. After a successful Auto Home click, the modal no longer re-prompts on the next jog in the same session — the "not homed" warning is gated on a session-scoped acknowledgement flag that was only being set by "Move anyway" and now also fires on successful Auto Home. Regression test covers all three axes arguments producing the same bareG28. Thanks to @mikefromdot for catching this with an undamaged retest. - AMS: Configure / Assign Spool Hidden on Reset Slots, and Assign Spool Missing Matching-Material Inventory (#1047) — Two separate symptoms from the same report. (1) After resetting an AMS slot from the printer UI, the Bambuddy printer card showed "Empty Slot" with no Configure or Assign Spool actions on hover, while the same slot in SpoolBuddy's AMS page still let the user re-configure it. Root cause: commit
c9efa4b8(#784) added atray?.state === 10gate to theEmptySlotHoverCardactions, intended to show the buttons only when a spool was physically present but not loaded (state=10) and hide them on truly empty slots (state=9). In practice, firmware often reportsstate=9(or nostatefield at all) after a user-initiated reset — even when a spool is still physically in the slot — so the actions disappeared exactly when the user needed them. The gate is redundant anyway (EmptySlotHoverCardis only rendered when the slot has notray_type, so it's definitionally empty from Bambuddy's perspective), and configuring an empty slot is a valid "tell the printer what will be loaded here" operation. The gate is now removed at both the standard-AMS and AMS-HT render paths. (2) After configuring a slot with a Generic profile (e.g. "Devil Design PLA Basic Red"), the Assign Spool modal didn't list the matching inventory spool unless the user enabled the "Show all spools" toggle. Root cause: the filter atAssignSpoolModal.tsx:144requirednormalizeValue(spool.slicer_filament_name) === normalizeValue(trayInfo.profile)— manually-added inventory spools typically don't haveslicer_filament_namepopulated, so they failed the exact-profile check even when the material matched. The filter now prefers an exact slicer-profile match when both sides advertise one, and falls back to partial material match in either direction (so e.g. a spool withmaterial="PLA"is selectable for a slot reporting"PLA Basic") when profile info is missing. (3) Once the matching spool was assignable, a "profile mismatch" confirmation dialog still warned on every assignment because Bambu Studio / OrcaSlicer slicer-profile names carry a printer/nozzle/variant qualifier after@(e.g."Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)") while the tray stores only the bare base name ("Devil Design PLA Basic"), andcheckProfileMatchcompared the full strings. Both the filter and the mismatch check now strip the@…qualifier before comparing, so identical base profiles are treated as a match. Regression test covers a spool with no slicer profile being surfaced for a slot whose profile + material are both set. Thanks to @TravisWilder for the report. - Skip Objects: Enlarged Preview Image Fails to Load on Auth-Enabled Instances (#1046) — Clicking the mini print-pr
Added
- Spoolman Unified Inventory UI — Replaced the Spoolman iframe with a native inventory UI that matches the local spool experience exactly. The Filament Inventory page auto-detects the active backend (local DB or Spoolman) and renders spools, filters, deep-links, and NFC write flows identically regardless of source. Spoolman spools are fully editable — material, weight, colour, storage location, cost — via a PATCH proxy that re-links the Spoolman filament on metadata changes. Bulk-create, archive, restore, and delete are all supported. A 207 Multi-Status response on partial bulk-create includes
requested_countandfailed_countso the UI can surface a useful "Created N of M" message. - Storage Location field — Spoolman's
locationfield is now exposed asstorage_locationin the unified inventory schema and editable from the spool detail panel. - Deep-link from AMS slot hover card — Clicking the filament chip on an AMS slot hover card deep-links directly to the matching spool in the inventory, whether it lives in the local DB or Spoolman. The link resolves by spool ID so it survives list reloads; a 404 shows a "Spool not found" toast instead of a generic error.
- Spoolman-aware NFC write — The SpoolBuddy Write Tag flow now falls back to Spoolman when a spool is not in the local DB. The NDEF payload is built from the Spoolman filament metadata; incomplete fields (missing
color_name,nozzle_temp_min, etc.) produce per-field warnings surfaced as a UI toast. After a successful NFC write the tag UID is persisted back to Spoolman'sextra.tagvia a safe key-merge that preserves all other custom extra fields. - Spoolman-aware scale weight sync — The SpoolBuddy scale endpoint reads
filament.spool_weightfrom Spoolman to compute the tare, falling back to 250 g when unset. Weight is written back to Spoolman'sremaining_weight.
Fixed
- Tag-clear preserves other Spoolman extra keys — Clearing a tag UID from the inventory PATCH endpoint previously sent
extra: {}, destroying any custom Spoolman extra fields. The endpoint now fetches the current extra dict, drops only thetagkey, and PATCHes the remainder. - Malformed Spoolman spool no longer 500s the entire inventory list — A spool with a missing or non-positive
idfield caused_map_spoolman_spoolto raiseValueError, crashingGET /spoolman/inventory/spoolswith HTTP 500. The list endpoint now logs-and-skips individual bad rows so the rest of the list is returned normally. - delete / archive / restore correctly return 404 on missing spool — Previously these endpoints returned HTTP 500 when Spoolman responded with 404. The service layer now raises
SpoolmanNotFoundErroron 404 andSpoolmanUnavailableErroron other failures; routes map them to 404 and 503 respectively. - SSRF guard applied to all SpoolBuddy Spoolman paths —
_get_spoolman_client_or_nonenow runsassert_safe_spoolman_urlbefore initialising the client; unsafe URLs are silently ignored with a warning log so devices continue operating.
[0.2.3.1] - 2026-04-20
Fixed
- Skip Objects: Enlarged Preview Image Fails to Load on Auth-Enabled Instances (#1046) — Clicking the mini print-preview thumbnail inside the Skip Objects modal opened a lightbox that showed a broken-image icon instead of the full-size plate preview. The thumbnail
<img>wrapped itssrcwithwithStreamToken()(which appends the short-lived camera-stream token to/api/v1/URLs that<img>tags can't attach anAuthorizationheader to), but the enlarged lightbox<img>used a bare${status.cover_url}?view=topso the browser's unauthenticated request was rejected by the backend. Both images now go throughwithStreamToken(). Thanks to @elit3ge for the report and screenshot. - P1S Print Dispatches Stuck at IDLE Due to task_id Int32 Overflow (#1042) — Since the #1011 fix switched
project_id/subtask_id/task_idfrom hardcoded"0"tostr(int(time.time() * 1000)), each submission sent a 13-digit epoch-millisecond value (~1.7×10¹²). P1S firmware (observed on 01.10.00.00) clamps oversized task identity fields to signed int32 max (2147483647), so every dispatch looked identical from the printer's perspective — it treated a fresh print as a continuation of the prior FAILED job, returnedresult: successforproject_file(command accepted), but then sat atgcode_state: IDLEwith an emptygcode_fileinstead of transitioning toPREPARE/RUNNING. Thanks to @EdwardChamberlain for pinpointing the exact line and suggesting the mod fix. The three identity fields are now set tostr(int(time.time() * 1000) % 2_147_483_647 or 1): modulo keeps values inside the signed-int31 window with a ~24-day uniqueness cycle (more than enough for reprint deduplication), andor 1guards against the astronomically unlikely zero case (the printer rejectstask_id=0). Regression testtest_submission_id_fits_signed_int32asserts all three IDs are< 2**31. Two of @EdwardChamberlain's other suggestions — resolvingbed_typefrom the sliced 3MF's per-plate JSON instead of hardcoding"auto", and gating dispatch success on an actual state transition toPREPARE/RUNNINGrather than onproject_file'sresult: success— are larger changes tracked separately. - FTP Download Zombie-Thread Race on Slow WiFi (#1014) — Users on 2.4 GHz WiFi with heavy neighborhood interference saw "Successfully downloaded" log lines for queued prints that Bambuddy nonetheless reported as failed, and the slicer file landed in
/app/data/archives/temp/with the File Manager unable to find it. Root cause:download_file_asyncwrapped the blocking FTPRETRinasyncio.wait_forwith a 30–60 s timeout (user-configurable viaftp_timeout), but the wrapped thread couldn't be cancelled. On a slow link the download would overshoot the timeout by 15–30 s, at which point_run()waited a hard-coded 0.5 s for the zombie to finish, gave up, and returned failure — which triggeredwith_ftp_retryattempt 2, whose_downloadspawned a brand-new FTP session that contended with attempt 1's still-running transfer. Attempt 1's zombie eventually completed and wrote the file to disk, but by then attempt 2 (and 3, 4) had long since run out their own timeouts with their own freshcompletiondicts and reported failure; the archive pipeline saw only the finalNonefromwith_ftp_retryand created a fallback archive row with no 3MF data, which is why Skip-Object couldn't find the plate's objects even though the 3MF was on disk. Two fixes: the 0.5 s post-timeout sleep is replaced with athreading.Eventthe worker sets in itsfinallyblock, and_run()waits for that event with a bounded grace ofmax(min(ftp_timeout, 30), 0.5)s — covering the slow-WiFi overshoot case without extending a genuinely stuck connection indefinitely. The log line now includes the grace window (timed out after Xs (plus Ys grace)). Regression testtest_download_file_async_timeout_waits_for_slow_zombiesimulates a 1.5 s zombie with a 1.0 s wait_for timeout; old 0.5 s sleep would give up, new 1.0 s grace salvages. The existingtest_download_file_async_timeout_no_salvage_when_incompletestill passes — a thread that never completes within the grace window still returns failure. Thanks to @heffe2001 for the detailed reproduction and support logs. - Obico: Cold-Start Capture Timeout Sticks in Status Banner (#172) — On the very first detection poll after a restart, the initial RTSP snapshot capture occasionally exceeded the 20 s
SNAPSHOT_CAPTURE_TIMEOUT(the first keyframe from the printer's camera can take a while on a cold RTSP connection). Subsequent polls every ~8 s recovered and captured in ~1.2 s, but the red× Failed to capture snapshot for printer Nbanner in Settings → Failure Detection → Status stayed up forever becauseObicoDetectionService._last_errorwas written on failure and never cleared on the next successful poll. The successful branch in_check_printernow clears_last_errortoNoneonce a capture + ML call + classification complete, so the banner reflects only errors from recent cycles. Configuration-level errors (missingexternal_url, missingml_url) still persist because they return before the clearing line — users still see them until they fix the setting. Regression test covers: seed_last_error, run one successful_check_printer, assert_last_error is None. Thanks to @fblix for the reproduction and screenshot. - Printer Card Controls Row Overflows in Chrome — At Medium card size on a wide viewport, the printer-card controls row (fan badges, airduct mode, print speed, bed jog, then Stop / Pause on the right) visibly overlapped in Chrome while rendering fine in Firefox and Safari. The controls-row layout had a
max-[550px]:flex-wraprule on the left badge group that only fires below 550 viewport pixels, so on a wide viewport with a narrow card the left group never wrapped — and since its badges don't truncate, Chrome painted the overflowing speed/bed-jog badges on top of the right-pinned Stop/Pause buttons. German locales made it obvious ("Pausieren" is 9 characters). The left group now uses unconditionalflex-wrap, so when badges don't all fit on one line they wrap inside the left cell instead of colliding with the right cell; the parent row also wrapsgap-yso Stop/Pause drops to a new line in the worst case. Pre-existing (commit4ff3e2a6, Feb 2026), surfaced while testing #939. - MQTT Smart Plug Subscription Lost After Every Restart (#1010) — Users integrating a Shelly (or any other) plug through an external MQTT broker (e.g. ioBroker, Zigbee2MQTT, Home Assistant's MQTT broker) saw the plug's power / state / energy readings go dark after every Bambuddy restart, and the only fix was to open Settings → Smart Plugs, rename the topic to a dummy value, save, rename it back and save again. Root cause: the startup restore path in
main.py(~line 4120) still used the legacy single-topic model (mqtt_topicplus*_pathkwargs), while the Settings UI save path had been upgraded to the newer per-type model (mqtt_power_topic/mqtt_energy_topic/mqtt_state_topiceach with their own paths, multipliers andmqtt_state_on_value). Plugs configured entirely with the new per-type fields got skipped at startup because theif plug.mqtt_topic:guard short-circuited — which is exactly what a Shelly-via-ioBroker setup looks like, since those publish power and state on separate topics. The "rename, save, rename back" workaround triggered the update endpoint, which was using the correct per-type code and re-established the subscription. Fix: extracted the topic-resolution +service.subscribe()call into a singlesubscribe_plug_to_mqtt(service, plug)helper inbackend/app/services/mqtt_smart_plug.pythat preserves legacy fallback, and routed the startup restore, create, and update routes all through it so future schema changes can't cause the three paths to drift again. Regression tests cover: per-type topics restored without a legacy topic set, legacy single-topic backward compat, per-type multipliers overriding legacy, per-type winning when both are set, the empty-config skip case, and topic-list de-duplication. Thanks to @saint-hh for the clear repro steps. - Large 3MF Uploads Archived as Corrupted ZIPs (#1032) — On bare-metal Raspberry Pi installs (armv7l / Python 3.11 / Bookworm), 3MF files larger than a few MB arrived complete via the virtual-printer FTP server but the copy into
data/archives/ended up not being a valid ZIP. The archive row was still written, the printer card looked fine, and the problem only surfaced later when opening the archive in the UI, whereGET /archives/{id}/platesloggedFailed to parse plates from archive N: File is not a zip fileand the thumbnail / plate / filament panels came up blank. Two things conspired:shutil.copy2takes the Linuxsendfile()fast path on Python ≥ 3.8, and a partial-return from that syscall silently truncated the destination for the upload sizes users hit; andThreeMFParser.parse()had a bareexcept: passaround itszipfile.ZipFileopen, so the archive pipeline kept going with empty metadata and left the bad file on disk. The copy is now an explicit chunked read/write withfsync()— no sendfile involved — with a post-conditionzipfile.is_zipfile()check that refuses to create the archive row (and cleans up the archive directory) when the source was a valid ZIP and the destination isn't, logging both sizes atERROR. The parser's silent catch now logs atWARNINGso corrupted 3MFs are visible in support bundles instead of disappearing into empty metadata. Regression tests cover small / multi-chunk copies, ZIP roundtrips, the post-copyis_zipfilesentinel on a truncated file, and the new parser WARNING. Thanks to @saint-hh for the detailed diagnosis. - Thumbnails Blank Until Reload After Sign-In — On auth-enabled instances, signing out and back in left the File Manager (and occasionally the Archives page) full of broken thumbnails until the page was manually reloaded. Thumbnail URLs are gated by a short-lived camera-stream token that
<img>tags can't send viaAuthorizationheaders, so the token is appended as?token=…at render time. Two race conditions conspired to break this: (1) the token query was keyed only on['camera-stream-token']and fired while the user was still on the login page, 401'd, and stayed cached — after sign-in nothing invalidated it; (2) when the token did eventually arrive, the global variable holding it was not reactive, so any File Manager / Archives page that had already rendered kept serving image URLs with no token. The token query now includes the user id in its key and is gated on!!user, so a new login always triggers a fresh fetch; and when the token transitions from null to a value,useStreamTokenSyncwalks the DOM once and updatessrcon every already-rendered<img>/<video>pointing at/api/v1/without the current token, reloading them in place. - P2S Firmware Check Shows Stale "Latest" Version (#1030) — On P2S (and X2D) the Firmware Info modal reported
01.01.01.00as the newest available release even though01.02.00.00had shipped on the Bambu Lab wiki weeks earlier, so the "update available" badge never appeared. Two silent regex mismatches in the wiki scraper caused_fetch_all_versions_from_wiki()to return an empty list: (1) the section-heading anchor parser required a dash between the version bytes and the release date (id="h-01020000-20260409"), but P2S and X2D publish anchors without the dash (id="h-0102000020260409"); (2) the text-based fallback only accepted ASCII parens around the date, while P2S, X2D, A1 and A1-mini headings render dates in full-width(YYYYMMDD)(U+FF08/U+FF09). When both paths failed, the code silently fell back to the Bambu Lab download page, which still lagged at01.01.01.00. The anchor regex now accepts an optional dash and the fallback accepts both paren styles; added regression tests for the no-dash anchor and full-width paren shapes. Thanks to @Minebuddy for reporting. - Library File Print-Usage Tracking (#1008) —
LibraryFile.print_countandlast_printed_atare now updated on every successful queued print completion. Previously both fields were defined on the model and displayed in the File Manager, but nothing ever wrote to them — every file in every library showed as never printed. Now counts increment cumulatively andlast_printed_atstamps the completion timestamp (UTC). Failed, cancelled and user-aborted prints are intentionally excluded, so the fields represent "successful usage" rather than "attempted usage." This unblocks sorting the File Manager by last-printed date and is a prerequisite for the scheduled-purge feature requested in #1008. Thanks to @cadtoolbox for the report.
Improved
- Color Catalog Default Filter Set to "All Manufacturers" (#1039) — Settings → Color Catalog opened with the manufacturer dropdown pre-filtered to Bambu Lab, so users searching for a third-party color had to change the dropdown to All Manufacturers on every visit. The page now defaults to All Manufacturers and lets you narrow down from there. Thanks to @VID-PRO for the suggestion.
- File Manager: Collapse Folders by Default (#996) — Added a Collapse toggle next to Wrap in the File Manager sidebar header. When enabled, the folder tree opens with only top-level folders visible on every page load; disabling it restores the previous fully-expanded default. Toggling the preference also immediately re-collapses/re-expands the current tree — no reload required. Persisted to localStorage under
library-collapse-folders, matching the existinglibrary-*preference pattern. Thanks to @AshieTashi for the request.
Changed
- Docker runtime image on Debian Trixie — The production Docker image now builds on
python:3.13-slim-trixieinstead of the Bookworm-basedpython:3.13-slim. Picks up ffmpeg 5 → 7 (HEVC/AV1 improvements for camera capture), OpenSSL 3.0 → 3.3, and two more years of APT package freshness. Frontend-builder stays on Bookworm until the Node.js image team publishes Trixie variants — users never see that stage.
[0.2.3] - 2026-04-19
New Features
- Move Build Plate from Printer Card (#791) — The printer card controls row now has a Z-jog badge between the speed control and the stop/pause buttons. Click the up/down arrows to move the build plate; click the middle label to switch the step size (1 / 10 / 50 mm). When the printer is not homed (typical right after a print finishes), the first jog opens a Bambu Studio-style warning modal with Home Z, Move anyway (bypasses soft endstops for this move), or Cancel. After the first "Move anyway" in a session, subsequent jogs skip the dialog. Disabled while a print is running. Backed by new
POST /printers/{id}/bed-jogandPOST /printers/{id}/home-axesendpoints, both gated behindprinters:control. Thanks to @cadtoolbox for the request. - Printer Card Status Badges & Quick Controls — The Printers page printer card now exposes new at-a-glance controls inspired by the Home Assistant Bambu Lab integration:
- Enclosure Door badge in the top status row (DoorOpen/DoorClosed icons, green when closed, yellow when open). Detection uses the right MQTT field per printer family —
home_flagbit 23 on X1/X1C/X1E and the top-levelstathex string bit 23 on P1/P2/H2 — and falls through the existing WebSocket push (status-change dedup key now includes door state, so toggling the door alone triggers a live badge update without waiting for the 30 s REST poll). - Airduct Mode badge beside the print speed control (Snowflake/Flame icons, sky for Cooling and orange for Heating). One-click dropdown switches the printer between cooling and heating via the existing
set_airductMQTT command. Gated to P2S/H2D/H2C/H2S. - Force Refresh menu entry in the printer card kebab menu (RotateCw icon) that re-requests a full
pushallMQTT status report from the printer without forcing a reconnect.
- Enclosure Door badge in the top status row (DoorOpen/DoorClosed icons, green when closed, yellow when open). Detection uses the right MQTT field per printer family —
- AI Print-Failure Detection via self-hosted Obico ML API (#172) — New Settings → Failure Detection tab wires Bambuddy to a self-hosted Obico
ml_apicontainer (no Obico account, no cloud, no WebSocket). While a print is running, the detection service periodically hands the printer's camera snapshot URL to the ML API, which returns YOLO failure-detection scores. Scores are smoothed over time using Obico's own EWM + short/long rolling-mean math (30-frame warmup, alpha = 2/13, short window ≈ 5 min at 10s/frame, long window ≈ 20 h) so a single noisy frame cannot trigger an action. Sensitivity (Low / Medium / High) scales the LOW/HIGH thresholds; when the smoothed score crosses HIGH, the configured action runs exactly once per print: Notify only, Pause print (MQTT pause command), or Pause and cut power (pause + turn off any smart plug linked to that printer). A per-printer toggle lets you monitor all connected printers or just a subset. The Status card shows whether the service is running, the active thresholds, each monitored print's current verdict (safe / warning / failure), and a live rolling detection history. Snapshots are captured locally with a 20 s timeout we control and stashed under a one-shot 32-byte nonce; the ML API fetches them via an unauthenticated/api/v1/obico/cached-frame/{nonce}URL that sidesteps Obico's hardcoded 5 s read timeout.
Improved
- Firmware Update Modal Shows All Announced Versions (#568) — The firmware update dialog now lists every version announced on Bambu Lab's wiki release history, not just the single newest one. Each row shows whether an offline firmware file is actually available for that version — rows marked Usable (green) can be installed, rows marked Unavailable (gray) are announced but have no downloadable package yet (common for hot-fix releases like
01.01.03.00which Bambu only ships as OTA). The currently installed version is highlighted with a blue Installed badge. Selecting any usable row swaps the release-notes block at the top to that version's notes and enables the Install button for it — including older-than-current versions, so you can roll back to a previous firmware without having to hand-flash a file. The wiki scraper was tightened to only extract version numbers from heading anchors (e.g.id="h-01030000-20260303") so incidental version mentions in release-note prose — like an AMS firmware reference in an H2D changelog — no longer get mistaken for H2D firmware releases. Thanks to @Cornelicorn for the request. - Spoolbuddy Device Controls in Settings (#962) — Each Spoolbuddy device card in Settings → Spoolbuddy now exposes five one-click actions alongside the existing Unregister button: Update (trigger daemon software update), Restart Browser (kiosk UI), Restart Daemon, Reboot (device), and Shutdown. Each action shows a confirmation dialog before queueing the command; buttons are disabled when the device is offline. Uses the existing
/spoolbuddy/devices/{id}/updateand/spoolbuddy/devices/{id}/system/commandendpoints — no new backend work needed. Thanks to @TravisWilder for the request. - Support Bundle Covers All Settings & SpoolBuddy — The support bundle / bug-report payload now dumps every row in the
Settingstable instead of filtering by a hard-coded allowlist: sensitive keys (tokens, passwords, URLs, paths, emails, etc.) have their values replaced with[REDACTED]but the key itself is kept, so new config flags automatically show up in future bundles without a code change. Also adds anintegrations.spoolbuddysection listing registered SpoolBuddy devices (firmware version, NFC/scale hardware, calibration, online state, uptime) — anonymized, no hostnames/IPs/device IDs. - Settings Search Finds More Cards — The cross-tab search field at the top of Settings now finds Sidebar Links, Spoolman, Spool Catalog, Color Catalog, all four Failure Detection sections, Advanced Email Authentication, SMTP Test, Authenticator App (TOTP), Email OTP, 2FA Linked Accounts, Single Sign-On (OIDC), LDAP Server Configuration, and the four Backup sub-cards (GitHub, History, Local, Scheduled). Powered by a new module-level registry (
frontend/src/lib/settingsSearch.ts) so future settings register themselves next to their component instead of being forgotten in a central array.
Changed
- Plate-Clear Confirmation Disabled by Default — New installs ship with Settings → Workflow → "Require Plate-Clear Confirmation" off. Multiple new users reported queued prints appearing to not start because the prompt was waiting for acknowledgement; opt in from Workflow if you want the confirmation gate.
Security
- Dependency Updates for Published Advisories — Bumped two dependencies flagged by vulnerability scanners.
python-multipart0.0.22 → 0.0.26 closes CVE-2026-40347 (GHSA-mj87-hwqh-73pj), a denial-of-service triggered by large preamble or epilogue data around a multipart boundary — the 0.0.26 release now skips the preamble before the first boundary and silently discards the epilogue after the closing one. Bambuddy usespython-multiparttransitively through FastAPI/Starlette for form and file-upload parsing, so any authenticated endpoint acceptingmultipart/form-data(e.g. backup restore, project thumbnail upload) was exposed.dompurify3.3.3 → 3.4.0 picks up the fix for GHSA-39q2-94rc-95cp (the function-formADD_TAGScould bypassFORBID_TAGS); Bambuddy's two call sites (ProjectDetailPage,ProjectPageModal) only use array-formALLOWED_TAGS/ALLOWED_ATTR, so the specific bypass was not reachable, but the bump still hardens the sanitizer against future misconfiguration and clears the audit warning.
Fixed
- Virtual Printer "Synchronizing device information" Timeout with OrcaSlicer on Linux (#927) — Follow-up to the
b069b521serial-adaptation fix. OrcaSlicer's Linux builds publish MQTT payloads with the C-string null terminator included in the length (same pattern as paho.mqtt.c #1198), so every decoded message arrived as{…}\x00. The virtual printer's strictjson.loads()raisedJSONDecodeError: Extra dataand the handler silently returned — no pushall, get_version, or project_file was ever answered, so the slicer hit its 60 s sync timeout and reconnected in a loop. Real Bambu firmware's mosquitto passed the trailing byte through, which is why direct LAN connections worked, and why print_queue mode was the only affected path (proxy mode tunnels MQTT to the real printer instead of running the VP broker). The handler now strips trailing\x00/whitespace before parsing and logs the raw payload on any remaining decode failure so future silent variants are visible in support bundles. Thanks to @EdwardChamberlain for the debug-enabled support log that made the null byte visible in the raw bytes. - SpoolBuddy Kiosk Unusable After Full-Mode Install — A bundled Bambuddy + SpoolBuddy install via
spoolbuddy/install/install.sh --mode fullproduced an unusable kiosk on first boot: Chromium raced ahead of uvicorn and showed "can't connect to localhost"; after a manual reload the kiosk URL/spoolbuddy?token=…was hijacked by Bambuddy's first-run wizard (AuthContextforce-redirects to/setupwheneverrequires_setup=true, regardless of the target path); the wizard asks for admin credentials, but a touch-only Pi has no on-screen keyboard; if the user skipped auth the browser landed at/instead of the kiosk, and if they tried to enable auth they were stranded. Standalone mode was unaffected because it runs against an already-configured remote Bambuddy. Fixed in three parts: (a) newbackend/app/cli.pywith akiosk-bootstrapsubcommand that in a single DB transaction creates a scoped API key (can_read_status=True,can_queue=False,can_control_printer=False) and upsertssetup_completed=true, so the first-run wizard never triggers and the kiosk URL loads the SpoolBuddy page directly; users can still enable authentication later from the admin UI and the pre-provisioned key keeps working. (b)install.shfull-mode now runs the CLI as the bambuddy service user immediately aftercreate_bambuddy_serviceandsed-replaces theCHANGE_ME_AFTER_SETUPplaceholder inspoolbuddy/.env. (c) The generatedspoolbuddy-kiosk-launchnow polls${backend_url}/healthwith a 60 s timeout before exec'ing Chromium, so cold boots wait for uvicorn instead of flashing the connection-refused error. The CLI is idempotent with--forcefor re-installs. - Bambu Lab X2D Support (#988) — Added X2D to the Add Printer and Edit Printer model dropdowns (both were missing the new model, so manual printer setup had no X2D option — auto-discovery via SSDP was unaffected). The newly released X2D (dual-nozzle, enclosed, hardened steel rod gantry, AMS 2 Pro compatible) identifies itself as internal model code
N6via SSDP/MQTT, and serials begin with20P9. Because neither the code nor the prefix existed in any of Bambuddy's model tables, multiple paths silently fell back to wrong defaults: the camera service routed to the chamber-image protocol on port 6000 (which the X2D doesn't speak) instead of RTSP on port 322 — the reporter sawChamber image: data is not a valid JPEGspam and no stream; the K-profile edit/delete path conditioned its in-placecali_idxwrite on the H2D serial prefix094and would therefore have treated X2D as a single-nozzle printer even though its dual-extruder layout matches H2D; the firmware-update check loggedUnknown printer model: N6; and the virtual-printer model registry had no way to emulate X2D. Added theN6 → X2Dmapping across every registry (PRINTER_MODEL_ID_MAP,PRINTER_MODEL_MAP,ETHERNET_MODELS,STEEL_ROD_MODELS,CHAMBER_TEMP_SUPPORTED_MODELS, firmware-check API keys and wiki path, virtual-printer SSDP product names and serial prefix, DB migrationvp_model_fixes), extendedsupports_rtsp()to matchX2display names and theN6internal code (camera now goes to port 322), expanded the dual-nozzle serial prefix check inkprofiles.pyand the K-profile delete command inbambu_mqtt.pyto also accept20P9so the H2D-stylecali_idxin-place edit path runs on X2D, added X2D to theis_h2dmodel-family gate that selects the integer-formattimelapse/bed_leveling/flow_cali/vibration_cali/layer_inspectfields in the MQTT print command, and added X2D to the frontend's door-badge and airduct-mode whitelists,mapModelCodelookups on both the Printers page and Spoolbuddy AMS page, and the MaintenancePage wiki-URL resolver (X2D inherits P2S's steel-rod lubrication, belt-tension, nozzle cold-pull and PTFE wiki pages, since its hardware is closer to P2S than to H2). Credit to @krautech for the report and the debug bundle, and to @legend813 for the initial PR (#989) that seeded most of the registry changes — the classification was corrected (X2D uses hardened steel rods like P2S, not carbon rods) and the dual-nozzle/K-profile gaps were added on top. - Print Speed Icon Not Updating Live When Changed on Printer (#993) — Changing the print speed mode from the printer's own panel (instead of from Bambuddy) did not update the speed icon on the Printers page card; the new value only appeared after a full page reload. The MQTT parser was already tracking
spd_lvland updatingstate.speed_levelcorrectly, but the WebSocket serializer (printer_state_to_dict) was missing the field — so live status pushes never carriedspeed_level, and the frontend's merge-over-old-cache update left the icon stuck on its previous value. The REST/statusendpoint used on initial page load already included it, which is why reloads worked. Addedspeed_levelto the WebSocket payload. Thanks to @chesterakl for reporting. - Camera Popup Shows "Valid camera stream token required" With Auth Enabled (#979) — When Camera View Mode was set to "Window" and authentication was enabled, clicking the camera button opened a popup that immediately failed with
"Valid camera stream token required", while the embedded overlay kept working. Two root causes: (1)window.open(...)passednoopenerin the popup features, which severed the opener link and prevented the browser from copying sessionStorage (where the auth token lives) into the popup — so the new window booted unauthenticated and thePOST /printers/camera/stream-tokenfetch returned 401, leaving the<img>src without the required?token=query param; (2) even once the token arrived,CameraPagecomputed its URL from the module-level stream-token cache on render and never re-rendered when the cache was updated in auseEffect, so the first paint locked in a tokenless URL that the backend kept rejecting. Fixed by droppingnoopenerfrom the camera popup features (same-origin, trusted window) so sessionStorage is inherited, subscribingCameraPageto thecamera-stream-tokenReact Query so it re-renders the moment the token resolves, and appending the token directly from the reactive query value instead of the effect-synced module cache — the<img>src stays empty until the token is ready, so no tokenless request ever leaves the popup. Embedded-overlay mode was unaffected. Thanks to @VREmma for the reproducer. - AMS Slot Changes Stop Reaching Printer After Long Idle (#887) — After printers sat idle for several hours, spool changes published by Bambuddy silently stopped reaching the printer — the UI updated but the printer ignored the command, and only a manual reconnect restored functionality. Root cause: the MQTT connection degraded into a zombie state where the receive path still worked (push_status telemetry kept flowing, so Bambuddy considered the connection alive) but the publish path was dead. The existing zombie detector — the developer mode probe — only ran on first connect when
developer_modewas unknown; after the initial probe cached the value, subsequent zombie states went undetected because neither the staleness timer nor the keepalive could distinguish a half-open connection from a healthy one. The MQTT client now tracksams_filament_settingcommand/response pairs: when a published command receives no response within 10 seconds, it's counted as unanswered. After two consecutive unanswered commands, the session is force-reconnected using the sameforce_reconnect_stale_session()mechanism. This catches zombie sessions at the moment the user encounters them — on their second failed spool change — rather than requiring a manual reconnect. Thanks to @RosdasHH for the detailed support bundles that made the diagnosis possible. - Obico Detection ML API Call Fails Silently With Empty Error (#172, #1003) — The previous attempt at #1003 (0.2.3b4 dev) switched Bambuddy to POST the JPEG bytes directly to Obico's ML API as multipart form data, hoping to eliminate the callback-URL dependency for users behind reverse proxies with external auth. That approach cannot work: Obico's
/p/endpoint is declaredmethods=['GET']upstream and only reads?img=URLas a query string (verified againstobico-server/ml_api/server.py). Flask's router rejected every POST with 405 Method Not Allowed before any handler ran, which is why the Obico container logs showed zero activity while Bambuddy kept reportingML API call failed for printer N:with a blank suffix —raise_for_status()on the 405 response produced an exception whosestr()rendered empty in this path. Reverted to the pre-#1003 nonce-URL approach: the detection loop captures the JPEG locally with a 20 s timeout, stashes it under a 32-byte single-use nonce, and hands Obico aGET /api/v1/obico/cached-frame/{nonce}URL that resolves in <50 ms (so Obico's hardcoded 5 s read timeout never races our RTSP keyframe wait). The cached-frame route is un-authenticated at the Bambuddy layer — the unguessable 32-byte nonce with ~30 s TTL IS the credential. The warning log now also falls back totype(exc).__name__whenstr(exc)is empty, so future silent exceptions can never produce a blank error again. For users behind reverse-proxy external auth (Authelia/Authentik/Cloudflare Access): the/api/v1/obico/cached-frame/path must be whitelisted from external auth — it's already public on Bambuddy's side. Thanks to @fblix for the ml-api-shows-zero-logs clue that pinpointed the 405 root cause. - Obico Detection Snapshot Killed by Stream Cleanup (#172) — Third wave of #172 — once the cached-frame fix landed,
fblixreported a permanent "Failed to capture snapshot" warning in the UI. The periodic camera stream cleanup task scans/procfor ffmpeg processes with Bambu RTSP URLs and kills any that aren't in the active-streams registry. The Obico detection service'scapture_camera_frame_bytes()spawns its own short-lived ffmpeg process to grab a single JPEG frame, but that process was never registered with the stream cleanup — so when the 60-second cleanup cycle happened to run during the 5–10 s capture window, it killed the ffmpeg as "orphaned" (exit code -9). The detection service recovered on the next poll, but the kill produced unnecessary error logs and a missed detection frame. Fixed by tracking capture PIDs in a module-level set (_active_capture_pids) and excluding them from the/proc-scan kill list. Thanks to @fblix for the detailed timing analysis. - Direct Print from Library Not Attributed to User — Clicking the Print button on a library file dispatched the job with no
created_by_id, so the resulting archive had no owner and the print didn't show up in per-user statistics. The Queue and Reprint paths already forwarded the authenticated user; the libraryPOST /files/{file_id}/printendpoint now does the same, reading the user from the JWT and passing it through to the dispatcher so direct prints are attributed like queued and reprinted ones. - Add/Edit Printer Modal Clipped on Short Viewports (#964) — On short or zoomed-in browser windows, the Add Printer and Edit Printer dialogs exceeded the viewport height with no scroll, hiding the lower fields (Access Code, Model, Location) and the Save button. Users had to zoom the browser out to complete the form. The modal overlay now scrolls and the card caps at
calc(100vh - 2rem)with internal overflow so every field stays reachable regardless of viewport height. Thanks to @MartinNYHC for reporting. - AMS Drying Silently Does Nothing (#971) — Clicking Start Drying on a supported printer (e.g. P1S with AMS 2 Pro) could publish the MQTT command successfully but leave the AMS idle with no UI feedback. Two issues: (1) the firmware rejects the command when
dry_sf_reasonreports a blocking state (most commonly code 8 — AMS 2 Pro external power adapter not plugged in — but also "AMS busy", "already drying", etc.), and Bambuddy parsed that array but never surfaced it to the user; (2) the payload sentfilament: "", which some firmwares treat as an invalid-field refusal. The/drying/startendpoint now inspects the livedry_sf_reasonfor the target AMS unit and returns a descriptive 409 (e.g. "Plug in the external AMS power adapter to start drying") instead of silently publishing, and backfills an emptyfilamentfrom the first loaded tray's type (defaulting toPLA) so the printer never rejects the command for a missing field. Thanks to @MartinNYHC for reporting. - Webhook Tokens Leaked into Logs When Debug Logging Enabled (Security) — Turning on Settings → Support → Debug Logging elevated the
httpxandhttpcoreloggers to DEBUG, which caused httpx to log the full URL of every outbound HTTP request. For Discord notifications and generic webhook notifications, the URL is the secret — the bearer token is embedded in the path — so any user who enabled debug logging (typically to capture logs for a bug report) was writing their Discord webhook token tobambuddy.logand then pasting it into GitHub issues or support bundles.httpx/httpcoreare now pinned toWARNINGregardless of the debug toggle;paho.mqttstill honours debug. If you enabled debug logging while notifications were sending, rotate any exposed Discord/webhook URLs — the token is in the path, so the whole URL must be regenerated in the provider's UI. - Queue Item Stuck in "Printing" When Start Command is Dropped (#967) — If the physical printer dropped or ignored the MQTT
project_filestart command (same half-broken-session shape as #887/#936), the queue item was permanently orphaned in theprintingstatus at 100% because the scheduler optimistically flipped the DB row toprintingright after the publish succeeded locally and had no watchdog to revert it. Recovery required manually editing the SQLiteprint_queuetable. A new watchdog now captures the printer's pre-dispatch state and polls for up to 45 s afterstart_print()returns; if the printer never transitions, the item is reverted topendingso the scheduler picks it up again, and the MQTT session is force-reconnected so the retry lands without a printer reboot. Thanks to @stringham for reporting. - Queued Prints Require Printer Reboot to Start (#936) — On some printers, a queued print would be uploaded via FTP and the
project_fileMQTT command would be sent, but the printer never transitioned out ofFINISH/IDLEand required a power cycle to unstick — after which it often started a previously cancelled print rather than the intended one. Root cause is a half-broken MQTT session (same shape as #887): the printer keeps publishing telemetry so Bambuddy reports it as connected, but our publishes on the command topic never reach the firmware. Existing recovery only triggered via the developer-mode probe path, which skips printers that already have a knowndeveloper_modevalue. The print-dispatch verifier now treats an unacknowledgedproject_file(state unchanged after 15 s) as the same "commands not reaching printer" signal and forces a fresh MQTT session so the next dispatch can land without a printer reboot. The existing dev-mode probe path is refactored to share the same helper. - Clear Plate Confirmation Bypassed on Power Cycle (#961) — With Auto Off enabled and another job queued, the smart plug would cut power when a print finished and immediately re-power when the scheduler saw the queue, at which point the printer booted fresh into
IDLEand the next job auto-dispatched without the "Clear Plate & Start Next" confirmation. Root cause: the plate-cleared gate lived only in the in-memoryPrinterManager._plate_clearedset, and the scheduler's idle check treatedIDLEas always-idle regardless of whether a previous finish had been acknowledged — so the gate was lost across both Bambuddy restarts and the IDLE-on-boot state transition. The gate is now anawaiting_plate_clearcolumn on theprinterstable, set byon_print_completewhen a print finishes or fails, cleared by the/printers/{id}/clear-plateendpoint and by the scheduler when it dispatches the next job, and rehydrated from the DB intoPrinterManageron startup._is_printer_idlenow short-circuits to not-idle wheneverrequire_plate_clearis on and the printer is awaiting ack, regardless of the currently reported state — so the prompt survives Auto Off cycles, Bambuddy restarts, and the printer booting back intoIDLE. The clear-plate endpoint no longer requires the printer to currently reportFINISH/FAILED(it accepts the ack whenever the awaiting flag is set), and the Printers page widget prompts based on the flag rather than the reported state. Thanks to @miaopas for reporting. - Insecure Temp File Creation in Backup Export — The manual backup download endpoint used
tempfile.mktemp(), which is vulnerable to a symlink race condition (CWE-377). Replaced withtempfile.mkstemp()which atomically creates the file, eliminating the TOCTOU window. - Spoolman Iframe Blocked After 0.2.3b4 Security Headers — The Spoolman page (Inventory → Spoolman iframe) failed to load when Spoolman was served from the same host as Bambuddy via a reverse proxy. The security-headers middleware added in 0.2.3b4 set
X-Frame-Options: DENYon every response, which blocked even same-origin iframing. Relaxed toSAMEORIGINso Spoolman (and any other same-origin tool behind the same reverse proxy) can be embedded again, while still preventing cross-origin clickjacking. - Large 3MF Print Restart Mid-Job Kept Duplicate Archive With Wrong Duration (#972) — Second wave of #972 reports — a reproducer on a 37.5 MB BambuStudio-pushed print to an A1 surfaced three distinct problems that compounded across a Bambuddy container restart mid-print. (1) Archive start_time lost: the print-start handler only deduped existing
printingarchives by filename and marked them cancelled once older than 4 h — so a 13 h print that had a restart 10 h in got its archive cancelled, a brand-new archive created withstarted_at = now(), and the final duration displayed as ~1.5 h for a job that actually ran 13 h. Fixed by persisting the MQTT-providedsubtask_idon every archive row (newsubtask_idcolumn, auto-added via the existing inline migration runner) and matching on that id first, regardless of age. Same id means same print; the row is resumed in place with its originalstarted_at. Also revivesStale-cancelled rows from the legacy path if an earlier Bambuddy version already ran the old cancel-then-recreate logic. (2) 3MF search retried non-existent paths for ~48 min: the path order was/cache/ → /model/ → /data/ → /data/Metadata/ → /, and every missing path burned the full retry budget (user hadftp_retry_count = 10with 30 s delay ⇒ 11 × 30 s × 4 missing paths ≈ 22 min before the real/root path was even tried). BambuStudio/OrcaSlicer actually push to/on A1-family printers, so the "most likely" path was tested last. Fixed by reordering to try/first, and by raising a newFileNotOnPrinterErrorsentinel fromdownload_to_filewhen the FTP response is a 550 (file not found) sowith_ftp_retry'snon_retry_exceptionsshort-circuits instead of waiting out the full delay ×11 retries against a path that will never have the file. Transient errors (425 "can't open data connection", SSL EOF, connection resets) still retry as before. (3) Same 36 MB downloaded twice — the cover-thumbnail endpoint and the archive-metadata handler each opened their own FTP session for the same file during the print, and the second session often hit 425 because the first was still using the printer's single FTP socket. Added a small in-memory_threemf_path_cachekeyed on (printer_id, normalized filename): whichever flow fetches the 3MF first populates the cache, the other flow reuses the file read-only, andon_print_completeevicts the entry + deletes the temp file. Normalization collapsesBroly_X,Broly_X.3mf,Broly_X.gcode.3mf,Broly X, and case variants to the same slot so both flows agree on the key. Net effect for the reproducer: what took ~48 min with a lost start time now takes seconds and the archive keeps its original row + timestamps. Thanks to @mstko for the reproducer and support bundles. - Large 3MF Files Silently Dropped After Print Finish (#972) — After large prints, the Files tab rows arrived with no thumbnail, no filament breakdown and no cost — the archive row got created as a fallback with no 3MF even when the file was sittable on disk. Two root causes in the 3MF-fetch path. (1) The configured
ftp_timeoutsetting (default 30 s, reporter had raised it to 300 s) was only plumbed through as the FTP socket timeout; the outerasyncio.wait_forwrappingrun_in_executorwas stuck on the hardcoded 60 s default, so the user's 300 s value never applied — every 3MF download was capped at 60 s regardless. (2)asyncio.wait_forcannot cancelrun_in_executorthreads: when the 60 s outer timeout fired, the executor thread kept runningftplib.retrbinaryand frequently completed the download successfully ~30–60 s later — logging"Successfully downloaded … N bytes"and caching the working FTP mode — but by then the async wrapper had already returnedFalse, so the retry loop kept re-attempting the same path, each attempt truncating the file the zombie thread had just written. After all 4 attempts the wrapper reportedfailed after 4 attemptsand the archive was persisted as a fallback (no 3MF, emptyfile_path). The async wrapper now (a) accepts and usestimeoutat each call site softp_timeoutcontrols both the asyncio deadline and the socket deadline, and (b) salvages a post-timeout success: when the executor thread has set an explicit completion flag and the file is on disk, the wrapper returnsTrueinstead of discarding the result. Also fixes a cosmetic//prefix in the directory-search download path (posixpath.joinreplaces string concatenation that produced"//file.3mf"when the search dir was"/"). Thanks to @MartinNYHC for the report and @PurseChicken for the P1S support bundle. - SD Card Badge Removed — After four rounds of fixes the printer-card SD status badge still flipped red on H2D when unrelated activity happened on the network (e.g. powering on an A1 caused every H2D to go red simultaneously). The underlying problem is that Bambu firmware SD-state signaling is not reliably derivable from MQTT: the legacy top-level
sdcardfield is only sent on some pushes with inconsistent typing, andhome_flagbits 8-9 are cleared on heartbeat pushes even when a card is inserted, with no reliable way to distinguish heartbeats from full status reports. The badge has been removed entirely from the Printers page card and the Printer Info modal. Underlyingstate.sdcardparsing is retained (simplified to a plain truthy read of thesdcardfield only, no morehome_flagderivation, no heartbeat latches) because the firmware-update precondition check still needs to know whether a card is inserted before starting an update. Thanks to @MartinNYHC for the extensive reporting across all four rounds. Previously, this entry described the H2D badge flap and its three attempted fixes — kept here for history: The original bug toggled between "inserted" (green) and "not inserted" (red) every few seconds on H2D. Root cause: the MQTT parser used a strict identity check (data["sdcard"] is True) on the top-levelsdcardfield, but real firmware ships that field inconsistently — bool on some models, int1, or a string enum like"HAS_SDCARD_NORMAL"on others — so any message carrying a non-bool value flipped the state toFalse. Fixed by deriving the badge fromhome_flagbits 8–9 (HAS_SDCARD_NORMAL/HAS_SDCARD_ABNORMAL) when present — the canonical firmware source, same as door and store-to-SD parsing — and falling back to a truthy check on the top-level field for firmwares that only send that. Follow-up: the badge was still flapping because Bambu firmwares send partial MQTT pushes that carry the legacysdcardfield alone (withouthome_flag), and the fallback was re-engaging on every such push. The parser now latcheshome_flagas the canonical source for the session once seen, so partial pushes carrying onlysdcardcan no longer flip the badge; the latch resets on reconnect so a firmware change still re-learns. Second follow-up: on H2D the badge still showed red on initial Printers-page navigation and flipped to green on reload, because H2D also sends heartbeat-stylehome_flagpushes where bits 8–9 are clear even when a card is inserted. Downgrades from true→false now require three consecutive clear reads (upgrades false→true still apply immediately), so a single heartbeat no longer turns the badge red. Third follow-up: the three-strike counter still lost the race on idle printers — once an A1 or other printer connecting nearby triggered a burst of MQTT activity, idle H2Ds could accumulate ≥3 heartbeat pushes before the next full status report and all flip to red simultaneously. Reworked the derivation: the legacy top-levelsdcardfield is now authoritative when present (truthy check covers bool/int/string firmware variants),home_flagbits 8–9 are only consulted on fullpush_statusreports (identified by the presence of multiple state markers likegcode_state,mc_percent,nozzle_temper,print_type,stg_cur, orams), and bare heartbeat pushes carryinghome_flagalone no longer affect SD state at all. Thanks to @MartinNYHC for reporting. - Archive Reprints Show Wrong Duration in Third-Party MQTT Monitors (#1011) — Re-printing a file from Bambuddy's archive caused external MQTT observers like OctoEverywhere to report wildly wrong durations: a 40 min job first reprint would show ~1 h 40 min, and a second reprint of the same file would compound further (~4 h for a ~45 min print), with the excess roughly matching the wall-clock gap since the previous archive replay. The same file printed via BambuStudio → Bambuddy proxy → printer reported correct durations every time. Root cause: the archive-reprint path built the MQTT
project_filecommand with hardcodedproject_id="0",subtask_id="0",task_id="0", andmd5="", while BambuStudio mints unique identity fields per submission. The printer uses those IDs to key per-job state (includinggcode_start_time), so when every reprint arrived under the sametask_id=0, the printer reused the prior job's start timestamp instead of emitting a fresh state-transition event — third-party tools that derive duration from that timestamp latched onto a stale value, and successive replays compounded the error.bambu_mqtt.start_print()now generates a per-submission millisecond timestamp forproject_id/subtask_id/task_idand a uniquemd5derived from the filename + timestamp, matching BambuStudio's per-submission-unique-ID behavior. Covers both archive reprints and direct prints from the Library. Thanks to @PurseChicken for the controlled A/B reproducer (Studio vs archive reprint) that pinpointed the divergence to the print-start command payload. - CSP Blocked Sidebar Iframes, Service-Worker Registration, and Google Fonts — The strict
Content-Security-Policyheader added in 0.2.3b4 broke three things at once: (1) custom sidebar links pointing at external HTTPS URLs (e.g. a Grafana/telemetry dashboard) rendered inExternalLinkPagewere blocked because noframe-srcwas declared and iframes fell back todefault-src 'self'; (2) the inline service-worker registration<script>at the bottom ofindex.htmlwas blocked byscript-src 'self', silently preventing the PWA service worker from installing; (3) the@importof Google Fonts' Inter fromindex.csswas blocked bystyle-srcandfont-src. Fixed by addingframe-src 'self' https:for user-configured HTTPS iframe targets, moving the inline SW-registration script into/sw-register.jssoscript-src 'self'covers it without needing'unsafe-inline'or per-build hashes, and allowinghttps://fonts.googleapis.cominstyle-srcandhttps://fonts.gstatic.cominfont-src.frame-ancestors 'none'is preserved so Bambuddy itself still cannot be framed cross-origin.
[0.2.3b3] - 2026-04-12
Improved
- AMS Drying Support for P2S — Remote AMS drying and queue auto-drying now work on P2S printers with firmware 01.02.00.00 or later. Previously P2S was hard-blocked from the drying feature.
New Features
- Scheduled Local Backups (#884) — Settings → Backup now includes a "Scheduled Backups" card that automatically creates complete backup snapshots (database + all data directories) on an hourly, daily, or weekly schedule with configurable time-of-day and retention count. Backups are written as ZIP files to a configurable output directory (defaults to
DATA_DIR/backups/), which Docker users can mount as a volume to their NAS or external storage. Each backup in the list can be downloaded, restored directly from the UI, or deleted individually. The manual backup download endpoint has also been optimized to stream directly from disk instead of loading the entire ZIP into memory, significantly reducing download wait times for large backups. Works with both SQLite and PostgreSQL installs. Fully localized across all 7 UI languages. - SpoolBuddy Device Management Tab — Settings → SpoolBuddy now lists every registered SpoolBuddy device with live connection status, system details (firmware, IP, CPU temperature, memory, disk, OS, daemon and system uptime), hardware health flags (NFC / scale OK), and an Unregister button gated by a confirm modal. Previously, when a daemon crash caused SpoolBuddy to register itself twice, the kiosk UI silently used only the first device and there was no UI path to delete the orphaned duplicate — administrators had to delete the row directly in the database. A new
DELETE /spoolbuddy/devices/{device_id}endpoint (gated byinventory:delete) handles the removal and broadcasts aspoolbuddy_unregisteredwebsocket event so other tabs refresh immediately. A yellow warning banner appears when more than one device is registered to flag likely crash-duplicates. If an online device is accidentally unregistered, it will re-register itself on its next heartbeat. The Settings tab header also shows a device-count badge and a green/gray bullet indicating whether at least one registered device is online. Fully localized in English, German, and Japanese. - Print Files Directly from Project View (#930) — The project detail page now lists the printable files from every linked library folder inline, with Play (Print Now) and CalendarPlus (Add to Queue) action buttons on each sliced file (
.gcodeand.gcode.3mf). No more round-tripping through File Manager to reprint project files. Prints triggered from the project view are automatically associated with the originating project, so the resulting archive shows up in that project's history without any manual assignment. Backend adds aproject_idquery parameter toGET /library/filesthat returns all files across linked folders in a single query (replacing the prior one-request-per-folder pattern) and validatesproject_idon both the direct-print and queue paths so a stale ID yields a 404 instead of a FK-constraint 500. Fully localized across all 7 UI languages. Thanks to @legend813 for the contribution. - Printers Page Search and Filters (#852) — The Printers page now has a live search bar and two filter dropdowns (status and location) to make finding specific printers in large setups easier, especially on mobile where Ctrl+F is impractical. Search matches printer name, model, location, and serial number (case-insensitive, whitespace-trimmed) and has a clear button. The status filter covers All / Printing / Paused / Idle / Finished / Error / Offline and is reactive to WebSocket status updates via a React Query cache subscription — so a print finishing while "Printing" is selected immediately removes the printer from the filtered list. The location filter is only shown when at least one printer has a location configured. All three filters are combinable; the controls are hidden when no printers are configured yet; and an empty-state message appears when no printer matches the current search/filters. Fully localized across all 7 UI languages. Thanks to @legend813 for the contribution.
- LDAP Default Fallback Group — Settings → Authentication → LDAP → Advanced now has a "Default group" selector. When an LDAP user authenticates but is not listed in any mapped LDAP group, they are automatically assigned to this fallback group instead of being left without permissions. Previously such users could log in successfully but landed on empty pages because every permission check failed. Leave the setting empty to preserve the old behavior. A warning is logged each time the fallback is applied so administrators can spot missing group assignments.
Changed
- SpoolBuddy Auto-Wake on NFC/Scale (#945) — The SpoolBuddy kiosk display now wakes automatically when a spool is placed on the scale or an NFC tag is scanned, without requiring a touch first. The daemon discovers the Wayland session from the shared runtime directory and toggles HDMI power via
wlopm, coexisting withswayidlewhich continues to handle touch-based wake independently. Gracefully degrades whenwlopmis not installed or no Wayland session is available. Thanks to @TravisWilder for the suggestion. - SpoolBuddy Kiosk LCD Now Powers Off on Idle (#937) — The SpoolBuddy kiosk's "screen blank timeout" setting previously only painted a black CSS overlay over the browser window; the HDMI panel's backlight stayed on indefinitely, wasting power and letting OLED/LED panels burn in. The blanking path is now moved down to the OS layer: the install script installs
swayidleandwlopm, and labwc's autostart launches a new watchdog (spoolbuddy/install/spoolbuddy-idle.sh) that queries the backend once on boot for the device'sdisplay_blank_timeoutand hands it toswayidle, which powers HDMI off viawlopm --off HDMI-A-1after the configured idle period and powers it back on viawlopm --onwhen labwc delivers any input event (touch, keypress). The redundant CSS overlay and its pointer/keyboard listeners have been removed fromSpoolBuddyLayout— one source of truth now. Screen blanking is opt-in:display_blank_timeout=0(the default) skips launching swayidle entirely and the display stays on forever, preserving current behavior for users who didn't pick a timeout. The default for users who newly enable blanking is 300 seconds. Changes made to the timeout in SpoolBuddy Settings → Display take effect on the next kiosk restart — tap Quick Menu → Restart Browser to apply without a full reboot. A newGET /api/v1/spoolbuddy/devices/{device_id}/displayendpoint (gated oninventory:update, same as the existingPUTand heartbeat endpoints) is what the kiosk-side watchdog reads, so no new permissions are required on the device's API key. The watchdog also writes a full startup trace (env vars, resolved timeout, the exactswayidlecommand it execs) to~/.cache/spoolbuddy-idle.logso any future breakage on a different kiosk setup is trivially diagnosable, and auto-detectsWAYLAND_DISPLAYfromXDG_RUNTIME_DIRwith a short retry loop in case labwc hasn't finished exporting its env by the time autostart runs. Thanks to @TravisWilder for reporting.
Fixed
- H2C Nozzle Rack Slot Numbering Off When Slot 1's Nozzle Is Mounted (#943) — The H2C nozzle rack card on the Printers page rendered every rack slot shifted by one position whenever the lowest-numbered slot (rack ID 16, displayed as "slot 1") had its nozzle currently picked up into a hotend. In that state the printer firmware omits the mounted slot's ID from
device.nozzle.infoentirely instead of sending an empty placeholder, so the rack arrived with 5 entries (IDs 17..21) plus the 2 L/R hotends. The frontend was computing its rack base ID viamin(present_ids), which then became 17 instead of the fixed 16, and every remaining nozzle was rendered one position to the left — the nozzle physically in slot 2 appeared as "slot 1", slot 3 appeared as "slot 2", and so on, with the single empty placeholder falling off the right end as a phantom "slot 6" that should have been the actual empty "slot 1". The rack base is now hardcoded to 16 to match the fixed H2C rack ID layout (already encoded in thetest_h2c_nozzle_rack_populated_with_8_entriesbackend test), so the empty slot stays anchored to its physical position regardless of which nozzle is currently in use. A frontend regression test exercises exactly this case (ID 16 missing, remaining slots in order) and asserts the rendered slot row reads[—, 0.2, 0.6, 0.8, 1.0, 1.2]. Thanks to @netscout2001 for reporting. - Energy Snapshot Capture Crashes on PostgreSQL — With an external PostgreSQL database configured, the hourly smart-plug energy snapshot loop (introduced with the #941 fix) logged
asyncpg.DataError: invalid input for query argument $2: ... can't subtract offset-naive and offset-aware datetimesevery hour and failed to persist any snapshots, so date-filtered energy statistics in total-consumption mode stayed empty on Postgres installs. The engine already had abefore_cursor_executehook that stripstzinfofrom bound datetime parameters before they reach asyncpg (thesmart_plug_energy_snapshots.recorded_atcolumn isTIMESTAMP WITHOUT TIME ZONEto match the rest of the schema), but the hook only stripped datetimes one level deep — when SQLAlchemy'sinsertmanyvaluesfeature batched multiple snapshot rows into a singleINSERT ... SELECT FROM (VALUES ...)statement, parameters arrived as nested containers (lists of tuples, or a list inside an outer container) and the inner datetimes slipped through untouched. The hook now recursively walks any nesting of dict/list/tuple and stripstzinfoat any depth, so every parameter shape SQLAlchemy may use is handled. SQLite installs were never affected (SQLite ignores tzinfo entirely). - Wrong Filament Color Name Shown on Printer Tab AMS Popup (#857) — PLA Translucent Cherry Pink (and other colors outside a small hand-maintained list) appeared as "Scarlet Red" on the Printer tab AMS slot popup, and was also auto-provisioned into the inventory under the wrong name on the first RFID read. Root cause: both the backend spool auto-provisioner and the frontend AMS popup resolved color names by looking up the Bambu
tray_id_namecode (e.g.A17-R1) in a hardcoded table, and when the exact code wasn't listed they fell back to a suffix-only lookup (R1 → Scarlet Red). The suffix half of that code is not globally unique across material families —A17-R1is PLA Translucent Cherry Pink, whileA01-R1is PLA Matte Scarlet Red — so the fallback was structurally guaranteed to produce wrong names for any color the hand-maintained list didn't happen to cover. The resolver has been rewritten to use the existingcolor_catalogtable (seeded fromcatalog_defaults.pyplus the FilamentColors.xyz sync) as the single source of truth. Backend lookup is now by hex color against the catalog; the frontend fetches a compact{hex: name}map once per session via a newGET /api/inventory/colors/mapendpoint (available to any authenticated user, not gated oninventory:read), stores it in aColorCatalogProvidercontext, and uses it for allgetColorName()calls. The hardcoded tables inbackend/app/core/bambu_colors.py,frontend/src/utils/colors.ts, andfrontend/src/pages/PrintersPage.tsxhave been removed entirely. Existing spools that were auto-created with a wrong name before this fix need to be renamed manually — the fix only affects new auto-provisioning and live display. Thanks to @lightmaster for reporting. - LDAP Auto-Provisioning Fails on Upgraded SQLite Installs (#794) — First LDAP login on an upgraded SQLite install hit
sqlite3.IntegrityError: NOT NULL constraint failed: users.password_hashand fell through to a 500 response, because theuserstable on disk had been created before LDAP support landed withpassword_hash VARCHAR(255) NOT NULL. The model was alreadynullable=Trueand the migration to drop the constraint existed, but only ran on PostgreSQL — SQLite was skipped entirely because it has noALTER COLUMN ... DROP NOT NULL. The migration now patchessqlite_masterdirectly viaPRAGMA writable_schemaand bumpsPRAGMA schema_versionso the current connection reloads the table definition without requiring a restart. Fresh installs were never affected (they go throughBase.metadata.create_allwhich uses the current nullable model). Thanks to @DylanBrass for reporting. - Energy Statistics Empty for Week/Month/Day in Total Consumption Mode (#941) — With "Total consumption" selected as the energy tracking mode, the Statistics page showed the correct kWh total for All Time but zero for every time-filtered range (Today, This Week, This Month, …). The backend fell back to summing per-print archive energy whenever a date filter was active, but in total-consumption mode the per-print column was often empty for two reasons: (1) the starting-kWh value was held in an in-memory dict (
_print_energy_start) that was lost on any backend restart mid-print, so prints that spanned a restart never got an energy delta computed; (2) historical prints from before a smart plug was added had no value at all. The fix replaces the in-memory dict with a persistedenergy_start_kwhcolumn on the archive row, and adds an hourly snapshot loop (smart_plug_energy_snapshotstable) that captures each plug's lifetime counter. The/archives/statsendpoint now computes date-range totals via per-plug(last-in-range − baseline)deltas from those snapshots, clamping counter resets to zero. A warming-up flag is returned (and rendered as a tooltip next to the Energy stats on StatsPage) when the query runs on incomplete snapshot history — e.g. right after upgrade, before the hourly loop has built up a baseline before the selected range — so the "low" values during the first hours after upgrading are explained in-product rather than misread as a bug. Fully localized across all 7 UI languages. Per-print energy tracking is now restart-resilient in all modes as a side-effect. Thanks to Mike (@TheMadMike23) for reporting. - Virtual Printer "Synchronizing device information" Times Out in Orca (#927) — OrcaSlicer's "Send job" flow sat on "Synchronizing device information…" until it gave up, even though the FTP upload itself worked when the user clicked "Send job anyway". The virtual printer's MQTT server gated all incoming command handling on
f"device/{self.serial}/request" in topic— if the slicer's cached serial for the VP didn't exactly equal the VP's computedself.serial(which depends on model prefix + per-VPserial_suffix), everyget_version,pushall, andproject_filepublish was silently dropped. Nothing was logged past the initial "MQTT publish to …" line, so the slicer never received apush_statusorget_versionresponse on its subscribeddevice/{serial}/reporttopic and hit its sync timeout. Status pushes, version responses, and project_file acknowledgments were also being published ondevice/{self.serial}/report, so even when the incoming check happened to pass, replies targeted a topic the slicer wasn't listening on if its serial had drifted. Both directions are now serial-adaptive: the handler accepts any authenticated publish on adevice/*/requesttopic, extracts the serial the slicer is actually using from the topic, stores it per-connection, and uses it for every outgoing status report, version response, print acknowledgment, and periodic push so responses always land on the topic the slicer subscribed to. The client's serial is cleared when the connection closes and when the server stops. Regression tests cover the mismatched-serial publish path, the non-request-topic rejection path, the pushall→status_report routing, and the client-serial lifecycle. - External Sidebar Link Icon Not Showing (#878) — Custom icons uploaded for external sidebar links rendered correctly in the edit dialog but were missing from the sidebar itself, and opening the icon URL directly returned
{"detail":"Valid camera stream token required..."}. The sidebar<img>tag inLayout.tsxused a raw/api/v1/external-links/{id}/iconURL, but that endpoint is protected by a query-string stream token (the same mechanism used for camera streams and archive thumbnails, because<img>tags cannot send Authorization headers). The edit dialog already routed throughapi.getExternalLinkIconUrl(), which wraps the URL viawithStreamToken(); the sidebar now does the same, so icons appear when auth is enabled. - Shortest Job First Toggle Disappears After Clicking (#879) — The SJF toggle badge on the queue page was rendered inside the Pending Queue section header, which is only shown when there is at least one pending item and the list view is active. Clicking the toggle often coincided with the scheduler starting the only pending print, at which point the Pending section unmounted and the toggle vanished along with it — making it look like the button had disappeared after clicking. The toggle has been moved to the top of the queue page, next to the list/timeline view switcher, so it stays reachable regardless of pending-item count, active filters, or the selected view mode.
- SpoolBuddy Update Fails in Docker with "no user exists for uid 1000/1001" — The SpoolBuddy remote-update flow shelled out to the OpenSSH
ssh-keygenandsshbinaries for keypair creation and command execution. Both binaries callgetpwuid(getuid())at startup and abort withNo user exists for uid <N>when the container runs under an arbitrary PUID that is not listed in/etc/passwd(the stockpython:3.13-slimimage only has an entry for root, so running withuser: "1000:1000","1001:1001", or any non-root user tripped the same error). The entire SpoolBuddy update path is now subprocess-free: keypairs are generated in-process via thecryptographylibrary (already a dependency), SSH commands run through the pure-Pythonasyncsshclient, and git-branch detection reads.git/HEADdirectly instead of shelling out togit. asyncssh also callsgetpass.getuser()for local~/.ssh/confighost matching, which hit the same passwd lookup failure; the Docker image now setsLOGNAME=bambuddy,USER=bambuddy, andHOME=/appsogetpass.getuser()resolves via env vars before touching the passwd database, andasyncssh.connect()is called withconfig=[]so it does not attempt to load~/.ssh/configat all. Branch detection also now looks for.git/HEADin the application root rather thansettings.base_dir— in Docker the data directory is a separate volume (DATA_DIR=/app/data) that never contains.git. Finally, the Docker build now bakes.git/HEADinto the image (.dockerignoreallows this single 20-byte file through the context filter) so the production image knows which branch it was built from; previously the.gitdirectory was excluded from the build context entirely, leaving the container with no git metadata and causing the SpoolBuddy update flow to always pullmainon the remote device regardless of which branch Bambuddy itself was built from. Native installs behave identically — they already worked because the running user was always in/etc/passwdand.git/HEADwas readable from the project root. Regression tests assert that neither keypair creation nor command execution spawns any subprocess, and that branch detection reads from the application root even when a decoy.gitsits inside the data dir. - Camera Stream "6 of 5" Reconnect Counter + ffmpeg Log Flood (#925) — Two bugs surfaced while investigating camera reconnect behaviour. First, the camera page briefly displayed "Reconnecting attempt 6 of 5" before giving up, because the attempt counter could be incremented to the maximum while the reconnect banner was still rendering. The displayed value is now clamped to the configured maximum. Second, every failed ffmpeg spawn logged the full ~20-line ffmpeg version/configuration banner, producing hundreds of lines of noise per failed camera click (one reported click produced 555 log lines across 30 retries). A new stderr summarizer strips the ffmpeg banner before logging so only the actual error lines remain. The underlying "camera service stops accepting new connections after prolonged uptime" behaviour in the X1C firmware is still under investigation.
- LDAP POSIX Primary Group Ignored — LDAP authentication only looked at groups that listed the user explicitly via
memberUid(supplementary group membership). A user's POSIX primary group — referenced by thegidNumberattribute on the user object and matching thegidNumberon aposixGroup— was ignored entirely, so users whose role came from their primary group landed without the expected permissions. The authenticator now also searches forposixGroupentries whosegidNumbermatches the user's primarygidNumber, and dedupes DNs case-insensitively before resolving the group mapping (LDAP DNs are case-insensitive by spec). - Support Bundle Leaks Virtual Printer IP Address — The debug support bundle included the
virtual_printer_remote_interface_ipsetting value unmasked insupport-info.json. The setting key didn't match any of the existing sensitive-key filters, so the raw IP address was included in the bundle. Added_ipto the sensitive key filter so IP address settings are excluded from support bundles. Log file content was already covered by the existing IPv4 regex redaction. - "Build Plate Cleared" Button Unclickable After Second Print (#912) — After completing the first queued print and confirming the plate was cleared, the "Build plate cleared — ready for next print" button became unresponsive after the second print finished. The React Query mutation's
isSuccessstate persisted from the first plate-clear confirmation, causing the component to render the static "Plate Ready" confirmation instead of the clickable button. The mutation state is now reset when the printer leaves the FINISH/FAILED state, so the button works correctly on every print cycle. - Spoolman Location Not Cleared When Spool Removed from AMS (#921) — When Spoolman auto-sync was enabled and a spool was removed from an AMS slot, its location in Spoolman was never cleared, causing "double-booked" slots where multiple spools shared the same location. The auto-sync callback set locations for newly inserted spools but skipped the cleanup step that clears stale locations. The location clearing logic now runs after every auto-sync cycle. Also fixed the single-printer manual sync endpoint which didn't track synced spool IDs, risking incorrect location clearing for location-matched (non-RFID) spools.
[0.2.3b2] - 2026-04-08
New Features
- Optional PostgreSQL Database Support — Bambuddy can now use an external PostgreSQL database instead of the built-in SQLite. Set the
DATABASE_URLenvironment variable (e.g.,postgresql+asyncpg://user:pass@host:5432/bambuddy) to connect to Postgres. SQLite remains the default when noDATABASE_URLis set. All features work with both backends including full-text archive search (FTS5 on SQLite, tsvector+GIN on PostgreSQL), backup/restore (file copy vs pg_dump/pg_restore), health diagnostics, and cross-database restore (import a SQLite backup into PostgreSQL with automatic type conversion and FK handling). - Shortest Job First Queue Scheduling (#879) — New SJF toggle badge on the queue page header. When enabled, the scheduler starts shorter print jobs before longer ones instead of FIFO order. A starvation guard ensures long jobs that get skipped once are protected from being skipped again — they move to the front of the queue on the next cycle. The queue display automatically reorders to show the scheduler's actual execution order. Print duration is cached on queue items at creation time from the 3MF metadata.
- Auto-Print G-code Injection (#422) — Configure custom start and end G-code snippets per printer model in Settings (Workflow tab) for bed-clearing systems like Farmloop, SwapMod, AutoClear, and Printflow 3D. When adding a print to the queue, enable "Inject G-code" to have the scheduler inject the configured snippets into the 3MF before uploading to the printer. The original file is never modified — injection creates a temporary copy for upload only.
- External Folder Subfolder Preservation (#890) — Scanning an external folder now mirrors the real directory structure into the file manager folder tree instead of flattening all files into the root. Subdirectories are created as child LibraryFolders with correct parent/child hierarchy, and files are assigned to their matching subfolder. Hidden directories are skipped when "Show hidden files" is disabled. Subfolders that are deleted from disk are automatically cleaned up on the next scan. Created subfolders inherit the parent's read-only and show-hidden settings.
- LDAP Authentication (#794) — Users can now authenticate against an LDAP/Active Directory server. Configure the LDAP server URL, bind DN, search base, and user filter in Settings > Authentication > LDAP. Supports StartTLS, LDAPS (SSL), and plaintext connections. LDAP groups can be mapped to BamBuddy groups (Administrators, Operators, Viewers) for automatic role assignment. Auto-provisioning creates BamBuddy accounts on first LDAP login when enabled. Local admin accounts remain as fallback when the LDAP server is unreachable. Password management features (change password, forgot password, admin reset) are automatically disabled for LDAP users.
- SpoolBuddy Quick Menu (#893) — Swipe down from the top of the SpoolBuddy display to open a quick-access control panel. Toggle printer power via smart plugs directly from the display, and manage the SpoolBuddy system with restart daemon, restart browser, reboot, and shutdown controls. All destructive actions require confirmation. The menu shows real-time smart plug state (ON/OFF) for each printer that has a linked power plug.
Improved
- Database Engine Info on System Page — The System Information page now shows the active database engine (SQLite or PostgreSQL) and its version in the Database section, making it easy to verify which backend is in use.
- Plate Number in Printer View (#881) — Printer cards and the stream overlay now show the plate number alongside the filename when printing plate 2+ of a multi-plate 3MF file (e.g. "MyModel — Plate 3"). Single-plate prints are unchanged.
- Printer Name in Queue for Model-Based Jobs (#881) — Queue items assigned to a printer type ("Any P1S") now show the actual printer name once the scheduler assigns a specific printer, instead of continuing to display the generic model target while printing or in history.
- AMS Drying Support for H2S (#886) — Remote AMS drying and queue auto-drying now work on H2S printers with firmware 01.02.00.00 or later.
- REST Smart Plug: Separate Power/Energy URLs and Unit Multipliers (#472) — REST/Webhook smart plugs can now use individual URLs for power and energy data instead of requiring all values in a single status response. Each value falls back to the shared Status URL when no separate URL is configured, so existing setups work without changes. Added power and energy multipliers for unit conversion (e.g., set energy multiplier to
0.001to convert Wh to kWh). Useful for platforms like ioBroker that expose each data point as a separate API endpoint.
Security
- Path Traversal in File Upload Endpoints — Archive upload endpoints (
/upload,/upload-bulk,/{id}/source,/source-by-name,/{id}/f3d,/{id}/timelapse) used the client-supplied filename directly in file paths without stripping directory components. An authenticated attacker could write files outside the intended directory via directory traversal (e.g.../../evil.3mf). All upload endpoints now sanitize filenames by extracting only the basename before constructing paths. Reported responsibly by Sacha Vaudey via security@bambuddy.cool. - Unauthenticated Bug Report Endpoints — The bug report endpoints (
/start-logging,/stop-logging,/submit) had no authentication, allowing anyone on the network to enable debug logging, retrieve system logs, and trigger bug report submissions with system diagnostics when authentication was enabled. All three endpoints now require authentication —start-loggingrequiressettings:updatepermission,stop-loggingandsubmitrequiresettings:read. Endpoints remain open when authentication is disabled (the default). Reported responsibly by Sacha Vaudey via security@bambuddy.cool. - API Key Empty Printer List Grants Full Access — An API key with an empty
printer_idslist ([]) was treated identically tonull(global access to all printers), granting full printer access instead of no access. Nownullmeans global access (admin key) and[]means no printer access. Existing API keys with empty lists are automatically migrated tonullon startup. Also fixed the webhook queue endpoint which used a falsy check that would bypass the filter for empty lists. Reported responsibly by Sacha Vaudey via security@bambuddy.cool. - Missing HTTP Security Headers — API responses did not include standard security headers. Added a middleware that sets
X-Content-Type-Options: nosniff(prevents MIME-sniffing),X-Frame-Options: DENY(prevents clickjacking via iframe embedding), andReferrer-Policy: strict-origin-when-cross-origin(limits URL leakage to external services) on every response.Content-Security-Policywas omitted because the React SPA uses inline styles extensively and a permissive CSP would provide no meaningful protection.Strict-Transport-Securitywas omitted because Bambuddy is a LAN application commonly accessed over HTTP — HSTS would lock users out. Reported responsibly by Sacha Vaudey via security@bambuddy.cool. - Camera Snapshot Temp Files World-Readable — Camera snapshot and plate detection endpoints created temporary JPEG files in
/tmpwith default 0644 permissions, making them readable by any local user. Switched fromNamedTemporaryFile(delete=False)tomkstempwith explicit 0600 permissions so only the application user can read them. Cleanup was already handled viafinallyblocks. Reported responsibly by Sacha Vaudey via security@bambuddy.cool.
Fixed
- Spool Weight Not Updated After Print (#839) — Filament usage tracking failed silently in several scenarios: (1) when FTP download failed and a fallback archive was created without a 3MF file, the primary tracking path was skipped entirely — now falls back to matching the 3MF from the library or a previous archive of the same file; (2) external/VT tray spools were never tracked by the AMS remain% fallback because it only iterated AMS unit trays — now captures and tracks VT tray remain% deltas; (3) notifications showed "Unknown" for time and filament on fallback archives — now enriches notifications with usage tracker results and captures estimated print time from MQTT at archive creation; (4) when auto-archive was disabled,
archive_idwas None at print completion so the entire 3MF tracking path was skipped — now searches library files and previous archives by filename to find the 3MF even without an archive, and captures the AMS slot-to-tray mapping at print start so it's available at completion regardless of archive state; (5) when auto-archive was disabled but the print was dispatched by BamBuddy (queue/reprint), the on_print_start callback discarded the expected print entry and returned early — the archive was never promoted to_active_prints, so at completionarchive_idandams_mappingwere both None, making all tracking paths fail. Now detects expected prints before the auto-archive early-return and falls through to the normal promotion path, also injecting the storedams_mappinginto the usage tracker session. - File Manager Stale UI After Deleting Folders/Files — Deleting a folder, file, or bulk-deleting items in the file manager appeared to succeed (toast shown) but the UI didn't update until a page reload. The delete endpoints (
delete_folder,delete_file,bulk_delete) relied on FastAPI's dependency cleanup auto-commit which runs after the response is sent — the frontend received the success response, refetched the folder/file list, but the delete hadn't been committed yet. Added explicitdb.commit()before returning in all three endpoints. - Spool Manager Deducts Double the Filament Used (#880) — After a print completed, the built-in spool manager subtracted twice the actual filament consumption. The printer's MQTT status message contains both updated AMS remain percentages and the
FINISHstate, which triggered two independent deduction paths in the same event loop cycle: the AMS weight sync (absolute SET from remain%) and the usage tracker (additive delta from 3MF data). The AMS weight sync now skips updates while a print session is active, letting the usage tracker handle deductions precisely via 3MF slicer data. - Thumbnails Broken After Backend Restart — Archive and library thumbnails returned 401 Unauthorized after a backend restart because stream tokens are stored in memory and lost on restart. The frontend now detects failed token-protected image loads and automatically refreshes the stream token, so thumbnails recover without a page reload.
- SpoolBuddy Kiosk Screen Blanks on Boot — The touchscreen display would blank immediately after the RPi booted, requiring a touch to wake. Added
consoleblank=0to the kernel cmdline to disable Linux console blanking during the Plymouth-to-labwc transition, and changed thewlr-randranti-blank loop to fire immediately instead of sleeping 60 seconds first. - Queue Widget Ignores Plate-Clear Setting (#752) — The "Clear Plate & Start Next" button on printer cards appeared even when "Require plate-clear confirmation" was disabled in Settings → Queue. The backend correctly auto-dispatched without waiting, but the frontend widget always showed the prompt. The widget now respects the setting and shows a passive queue link instead when plate-clear confirmation is disabled.
- Ghost Jobs From SQLite Lock on Print Completion (#897) — When a print finished, the queue status update (
printing→completed) could fail silently if the SQLite database was locked by another writer (e.g. the runtime tracker). The failed commit left the job permanently stuck inprintingstatus — a "ghost job" that caused the UI to show false double-assignments when the next job started. The critical queue status commit now retries up to 3 times with backoff on SQLite lock errors (PostgreSQL is unaffected — it uses row-level locking). Additionally, the runtime tracker was holding a single long transaction across all printers; it now commits per-printer to minimize lock hold time. - Multi-Plug Automation Only Works for First Plug (#903) — When multiple smart plugs were assigned to the same printer (e.g. a TUYA printer plug and a particle filter plug via Home Assistant), only the first plug's automation worked. The auto-on at print start, auto-off at print completion, and queue auto-off all queried for a single plug instead of iterating all plugs linked to the printer. All automation paths now control every assigned plug. Also fixed the queue auto-off path which was hardcoded to Tasmota instead of using the correct service for the plug type (HA, MQTT, REST).
- SpoolBuddy Inventory Not Updating on Spool Changes — Adding, editing, deleting, archiving, or restoring a spool in the internal inventory did not update SpoolBuddy's frontend views until the next manual refresh or 30-second poll. The spool CRUD endpoints did not emit websocket events, and the SpoolBuddy Dashboard had no polling fallback. All inventory mutation endpoints now broadcast an
inventory_changedwebsocket event, and the frontend invalidates the spool cache on receipt — so SpoolBuddy (and all other tabs) reflect changes instantly. - AMS Slot Changes Fail Until Reconnect (#887) — After a keep-alive timeout, paho-mqtt auto-reconnects but the new session can be half-broken: the printer continues sending status updates but silently ignores commands. The developer mode probe detected this (no response, leaving
developer_modeasnull), but had no timeout or recovery — one unanswered probe permanently blocked retries. Added a 10-second probe timeout with one retry; after two consecutive unanswered probes, Bambuddy force-closes the socket to trigger a clean reconnect with a fresh session. Additionally, the developer mode probe was firing on every auto-reconnect, which destabilized some firmware MQTT brokers (A1/P1 series) — causing a reconnect → probe → disconnect feedback loop. The probe result is now cached across reconnects and only runs once on the first connection, with a 5-second delay after connect to let the session stabilize. - WebSocket Crash on Printers Without
funField (#873) — Connecting to printers that don't send the MQTTfunfield (A1, P1 series, X1Plus firmware) caused a repeating'str' object has no attribute 'get'crash in the WebSocket handler, showing the printer as offline with missing AMS and SD card info. The developer mode probe introduced in 0.2.3b1 published an MQTT message inside_update_state()between overwritingraw_datawith the full MQTT dict (wherevt_trayis a raw dict) and restoring the previously normalized list — thepublish()call released the GIL, letting the event loop read the un-normalized dict and iterate over string keys instead of spool dicts. Fixed by normalizingvt_traydict→list in the MQTT data before assignment, and moving preserved field restoration before the probe. Added defensive normalization inprinter_state_to_dictas a belt-and-suspenders guard.
[0.2.3b1] - 2026-04-02
New Features
- Queue Timeline View (#823) — The queue page now has a production schedule view showing when each print is estimated to finish. Events are sorted chronologically and grouped by hour, with cards showing the file name, printer, estimated completion time, and time remaining. Active prints show a live progress bar. Filter by "Show All", "Printing", or "Queued", and navigate between days. Click any event to edit or stop it. Toggle between List and Timeline views with the button group above the queue.
- Staggered Batch Start for Multi-Printer Jobs (#752) — When sending a print to multiple printers via the queue, you can now stagger the starts to avoid power spikes from simultaneous bed heating. Enable "Stagger printer starts" in the schedule options to define a group size (how many printers start at once) and interval (minutes between groups). For example, 10 printers with group size 2 and interval 5 min will start in 5 waves over 25 minutes. Default group size and interval are configurable in Settings → Queue. Works with both ASAP and Scheduled timing — ASAP starts the first group immediately, subsequent groups get computed scheduled times. The stagger option is also available in the direct Print dialog when multiple printers are selected — prints are automatically queued with staggered start times, so you can close the browser and walk away.
- Plate-Clear Confirmation Setting (#752) — New "Require plate-clear confirmation" toggle in Settings → Queue. When disabled, the scheduler starts queued prints automatically on printers with finished jobs without waiting for per-printer plate confirmation. Useful for farm workflows where plates are verified physically before starting a batch. Default is enabled (existing behavior preserved).
- Settings Queue Tab — New dedicated Queue tab in Settings consolidates queue-related settings: staggered start defaults and auto-drying configuration (moved from the Filament tab).
- Per-User Statistics Filtering (#730) — Admins can now filter the Statistics page by user. A user dropdown appears in the stats header for users with the new
stats:filter_by_userpermission (Administrators only by default). Filter by a specific user to see their prints, filament usage, and costs, or select "No User (System)" to view prints without user attribution (e.g. slicer-initiated or pre-auth prints). The filter applies to all stats widgets and exports. - Bulk Printer Actions (#825) — Select multiple printer cards and apply bulk actions from a floating toolbar. Toggle selection mode from the header, then click cards to select. Use "Select All", "Select by State" (printing, paused, finished, idle, error, offline), or "Select by Location" to quickly pick printers. Available actions: Stop, Pause, Resume, Clear Notifications, and Clear Bed — each button is smart-enabled based on the selected printers' current states. Confirmation modals for destructive actions (Stop, Pause, Clear Bed). The status summary bar now shows all printer states (printing, paused, finished, idle, error, offline).
- Prefer Lowest Remaining Filament (#805) — New optional setting in Settings → Filament that prefers AMS spools with the lowest remaining filament during auto-matching. When multiple spools match the same type and color, the one with the least filament remaining is selected first. Helps consume partial spools before starting new ones. Applies to queue scheduling, print modal, and multi-printer mapping. Unknown remain values (e.g. external spools without sensors) are treated as full. Disabled by default.
- REST/Webhook Smart Plug Type (#472) — New "REST" smart plug type for controlling power via generic HTTP APIs. Works with any home automation platform that has an HTTP endpoint (openHAB, ioBroker, FHEM, Node-RED, etc.). Configure separate ON/OFF URLs with custom HTTP methods (GET/POST/PUT/PATCH), request bodies, and headers. Optional status polling via a GET endpoint with JSON path extraction for state, power, and energy monitoring. Fully controllable — supports auto on/off with prints, daily scheduling, sidebar quick-toggle, and power alerts.
- Configurable Default Print Options (#858) — Print options (bed levelling, flow calibration, vibration calibration, first layer inspection, timelapse) now have configurable defaults in Settings → Workflow. Set your preferred defaults once and every new print dialog starts with those values. Still overridable per print.
- Batch Print Quantity (#342) — Print multiple copies of a file in one step. The print and schedule dialogs now have a quantity field — set it to any number and the system creates that many queue items automatically. When quantity is greater than one, items are grouped into a batch for tracking. In the direct print dialog, the first copy prints immediately while the remaining copies are queued. The queue page shows a batch badge on grouped items. Batch progress and cancellation are available via the API.
- GitHub Backup: Spool Inventory & Print Archives (#870) — GitHub backup can now include spool inventory and print archive history as optional toggles alongside the existing K-profiles, cloud profiles, and settings. Spool backup exports all spools with their material, brand, color, weight, cost tracking, RFID tags, and full usage history. Archive backup exports print history metadata (filament, temperatures, times, costs, energy) — no gcode/3MF binary files. Both are off by default and can be enabled independently in Settings → Backup & Restore.
Improved
- Standardized Webhook Notification Payloads (#871) — Custom webhook notifications now include structured event data fields (
event,printer,filename,duration, etc.) alongside the existingtitle,message,timestamp, andsourcefields. Previously, onlytitleandmessagewere sent, requiring automation tools to parse the message text for event details. All event-specific template variables are now included as top-level JSON fields, making it easy for n8n, Node-RED, Home Assistant, and other automation platforms to route and process notifications based on structured data. Slack/Mattermost format is unchanged. - Queue Page Visual Refresh — Compact stats bar replaces the five summary cards (saves vertical space), color-coded left borders on all queue items for instant status scanning, collapsible history section (collapsed by default), and condensed single-line rows for history items showing more prints at a glance.
- Developer Mode Detection for A1/P1 Printers — Printers that don't send the
funfield in MQTT status (A1, P1 series) now have developer mode detected via a probe command. After receiving the first full status update, Bambuddy sends a no-op external slot configure and checks whether the printer accepts or rejects it (mqtt message verify failed). Printers that do send thefunfield (X1C, H2D, etc.) continue to use the existing bit-based detection. Developer mode state is re-checked on every reconnect.
Fixed
- Bed Cooled Notification Never Firing (#872) — Replaced the polling-based bed cooldown monitor with an event-driven approach. The old implementation polled cached bed temperature every 15 seconds for up to 30 minutes after print completion, but some printer firmware (e.g. P2S 01.00.05.00) stops including
bed_temperin MQTT updates after a print finishes — even in response to pushall requests — causing the cached value to stay frozen at the end-of-print temperature until the monitor timed out. The new approach registers a waiter at print completion and reacts instantly whenbed_temperdata arrives via MQTT, whenever that may be. No timeout, no polling, no stale data — the notification fires as soon as the printer reports the bed is at or below the configured threshold. - Filament Color and Subtype Inconsistencies (#857) — Fixed several filament identification issues: (1) AMS slot popup showed generic color names like "Dark Gray" instead of Bambu-specific names like "Titan Gray" because the fallback skipped the Bambu hex color database. (2) "Silk+" subtype was missing from the known variants list, so the Edit Spool dropdown showed "Silk" instead. Also added "Tough+". (3) Gradient and Dual Color filaments were misclassified — PLA Basic Gradient was detected as "Basic" and PLA Silk Dual Color as "Silk" because the firmware only sends the base material in
tray_sub_brands. Now detects gradient/multi-color/tri-color variants from thetray_id_namecolor code pattern (M*/T* suffixes). - External Spool Print Fails on Printers With AMS (#854, #859) — Two related issues with external spool printing: (1) Sending a print to a printer with no AMS units and only an external spool caused "Failed to get AMS mapping table" because the command was sent with
use_ams: true. Now automatically setsuse_ams: falsewhen all filament slots map to external spools. (2) Printers with an AMS connected but empty (e.g. X1C withams_exist_bits=1, tray_exist_bits=0) got stuck at heatbed heating or hit the same 07FF_8012 error because the print command usedams_id: 254inams_mapping2instead of255. The firmware interpreted 254 as a physical AMS tray target instead of external spool. BambuStudio usesams_id: 255(VIRTUAL_TRAY_MAIN_ID) for single-nozzle external spool. Fixed by mapping external spool toams_id: 255on all non-H2D printers. H2D dual-nozzle printers retain 254 (deputy) / 255 (main) distinction. - External Folder Scan 500 Error on 3MF Files (#846) — Scanning an external folder containing .3mf files crashed with "Object of type bytes is not JSON serializable". The parsed 3MF metadata contained raw thumbnail bytes (
_thumbnail_data) that were stored directly in the database JSON column without cleaning. Also removed a call to the non-existentparser.extract_thumbnail()method — thumbnail data is already available in the parsed metadata. Now uses the sameclean_metadata()pattern as upload and zip extraction. - Archives Capped at 50 Items (#843) — The archives page only showed the 50 most recent prints due to a hardcoded API limit. Users with more than 50 archives could not see or access older entries. Fixed by fetching all archives and adding client-side pagination with configurable page sizes (25, 50, 100, 200, or All). Page size preference is persisted.
- Filament Usage Not Recorded When Auto-Archive Disabled — When a printer had "Auto-archive completed prints" turned off, filament consumption was silently lost. The
on_print_completecallback returned early before reaching the usage tracking code, so neither the internal inventory (AMS remain% deltas) nor Spoolman received usage data. Moved filament tracking to run before the archive check so usage is always recorded regardless of the auto-archive setting. - H2D External Spool Uses Wrong Nozzle (#836) — Prints sent from Bambuddy to dual-nozzle printers (H2D, H2D Pro) with external spools always routed to the wrong nozzle. The old
ams_mapping2format used a sharedams_id: 255withslot_id: 0/1to differentiate external slots, but the firmware interpreted slot_id as the nozzle index (0=main/right, 1=deputy/left), routing filament to the opposite nozzle. Already fixed by the #797ams_mapping2format change (per-trayams_idinstead of shared unit), but users on older builds still experience this. Printing the same file directly from the slicer worked correctly. - SpoolBuddy "Add to Inventory" Failed Silently — The quick-add button on the SpoolBuddy kiosk did nothing when tapped. The scale weight was sent as a float but the backend requires an integer, causing a Pydantic validation error. The error was silently caught with no user feedback, leaving the confirmation modal stuck open. Fixed by rounding the weight before sending, moving the modal close to a
finallyblock, and adding an error toast with the actual API message. - SpoolBuddy Dashboard Crash on Null Spool Fields — Viewing a spool with null
subtype,brand,rgba, orcolor_nameon the SpoolBuddy dashboard crashed the UI (black screen). The spool prop construction useddisplayedSpool?.subtype ?? sbState.matchedSpool!.subtype— when the field wasnull, the??operator fell through tosbState.matchedSpoolwhich could also be null, causing a TypeError. Fixed by picking one source object instead of mixing per-field fallbacks. Added a global React error boundary so future crashes show the error instead of a black screen. - Plate Thumbnails 401 in Print Modal — Multi-plate 3MF plate thumbnails in the print modal returned 401 Unauthorized when authentication was enabled. The backend returns bare URL paths for plate thumbnails, but the
PlateSelectorcomponent used them directly in<img src>without appending the stream token. Fixed by passing the URL throughwithStreamToken(). - Schedule Calendar Picker Opens Off-Screen — Clicking the calendar icon in the print modal's scheduled mode opened the native date picker at the bottom of the viewport instead of near the date field. The hidden
datetime-localinput usedsr-onlypositioning which anchored the picker off-screen. Fixed by positioning the hidden input inside the date field's container. - SpoolBuddy Kiosk Display Blanking and Crashes — The kiosk Chromium flags added in 0.2.2.2 caused display instability:
--js-flags=--max-old-space-size=128crashed the V8 renderer when heap exceeded 128 MB,--enable-low-end-device-modeaggressively killed GPU rendering surfaces, and resettingCHROMIUM_FLAGSdiscarded the Pi's GPU defaults (--enable-gpu-rasterization, ANGLE/GLES) creating an unstable mixed CPU/GPU rendering path. Fixed by removing both flags, appending kiosk flags to Pi defaults instead of replacing them, adding awlr-randrkeep-alive loop to prevent display blanking, and adding<screenBlankTimeout>0</screenBlankTimeout>to the labwc config. - Sidebar Bottom Icons Cut Off With Smart Plugs (#862) — Adding smart plug buttons to the sidebar caused the bottom icon row to overflow and get partially cut off. The footer section could be compressed by the flexbox layout when the navigation area grew. Fixed by preventing the footer from shrinking, allowing the expanded icon row to wrap, and adding scroll overflow to the collapsed sidebar icon stack.
- AMS History Cleanup Crash Every ~24 Hours — The periodic cleanup of old AMS sensor history entries failed with "can't compare offset-naive and offset-aware datetimes". The cleanup cutoff used
datetime.now(timezone.utc)(timezone-aware) but therecorded_atcolumn stores naive datetimes via SQLite'sfunc.now(). The mismatch caused a TypeError when SQLAlchemy processed the comparison. Fixed by using a naive UTC datetime for the cutoff. The error only appeared once per ~24h because the cleanup runs every 288 recording cycles (288 × 5 min = 24h). - SpoolBuddy Status Bar Not Updating on Printer Switch — The bottom status bar on SpoolBuddy kiosk pages showed stale warnings (e.g. low filament) from the previously selected printer after switching to a different printer via the dropdown or swipe gesture. Two issues: (1) the AMS data cache was a single ref shared across all printers, so switching to a printer whose status hadn't loaded yet fell back to the previous printer's cached AMS data; (2) the Layout's alert useEffect unconditionally cleared alerts to null when the device was online, which could overwrite printer-specific alerts set by child pages. Fixed by keying the AMS cache per printer ID and tracking Layout-owned alerts separately so child page alerts aren't clobbered.
[0.2.2.2] - 2026-03-27
New Features
- Persistent Auto-Off for Smart Plugs (#826) — Smart plugs now have a "Keep Enabled" toggle under Auto Off settings. When enabled, auto-off stays active between prints instead of requiring manual re-enablement after each print (one-shot). Useful for accessories like BentoBox filters on Home Assistant switches that should always power off when a print completes. Default behavior (one-shot) is unchanged. Requested by @AeroMaestro.
- Missing Spool Assignment Notification (#763) — When a print starts and the AMS mapping references tray slots without assigned spools, Bambuddy now shows a warning toast in the frontend and can send push notifications via any configured notification provider. The notification includes the printer name, missing slot labels (e.g. A2, Ext-L), and expected material profile. A new "Missing Spool Assignment" toggle is available under Print Events in notification provider settings (off by default). Fully integrated with i18n (all 7 locales). Contributed by @Keybored02.
- Mid-Print Spool Reassignment Tracking (#763) — Usage tracking now correctly handles spool changes during a print. If a spool assignment is changed after a print starts, the system uses the live assignment for filament deduction; otherwise it falls back to the snapshot taken at print start. This ensures accurate filament tracking even when swapping spools mid-print. Contributed by @Keybored02.
- Auto-Link Untagged Inventory Spools on AMS Insert (#538) — When a Bambu Lab spool is inserted into the AMS and no existing tag match is found, the system now checks if there is an untagged inventory spool with the same material, subtype, and color. If found, the RFID tag is automatically linked to that existing spool instead of creating a duplicate entry. Uses FIFO ordering (oldest spool first) so spools are consumed in purchase order. Matching is case-insensitive. Requested by @wreuel.
- External Folder Mounting for File Manager (#124) — Host directories (NAS shares, USB drives, network storage) can now be mounted into the File Manager without copying files. Click "Link External" to point at a Docker bind-mounted path. Files are indexed into the database on scan but accessed directly from their original location — nothing is copied. Supports read-only mode (default, blocks uploads/moves/deletes), hidden file filtering, and automatic thumbnail extraction for 3MF, STL, gcode, and image files. External folders show a distinct icon and info bar with a rescan button. Deleting an external folder only removes the database index, never the actual files. Requested by @S1N4X.
Improved
- SpoolBuddy Kiosk Performance Optimizations — Reduced idle CPU load on Raspberry Pi from ~3.3 to ~0.9. Frontend: replaced expensive CSS animations on the idle dashboard (
animate-pingwith scale transforms,blur-2xlglow, continuousanimate-pulseon status dots) with static elements and a slow color-cycling spool (5s interval). Chromium: added--disable-extensions,--disable-background-timer-throttling,--disable-renderer-backgrounding, and--disable-crash-reporterto/etc/chromium.d/spoolbuddy-kiosk. WebSocket: SpoolBuddy Dashboard and Layout pages now use React Queryselectto extract onlyconnectedstatus from printer queries, so temperature/fan/progress updates no longer trigger re-renders on every MQTT tick. Services: stripped services are now masked (not just disabled) to prevent socket/dbus reactivation; user-level services (xdg-desktop-portal, mpris-proxy, pipewire, etc.) are masked globally via/etc/systemd/user/overrides instead of unreliablesu -l systemctl --user. Removed chromium and upower fromstrip_packagessince the kiosk needs them — they were being uninstalled then immediately reinstalled on every run. - SpoolBuddy AMS Slot Action Picker — Clicking an AMS slot on the SpoolBuddy AMS page now shows a picker with contextual actions: Configure AMS Slot (set filament preset, K-profile, color), and either Assign Spool / Link to Spoolman (when no spool is mapped) or Unassign / Unlink (when one is). Works with both internal inventory and Spoolman. Previously the slot click went straight to the configure modal with no way to manage spool assignments.
- Unassign Button in Edit Spool Modal — The edit spool modal now has an "Unassign" button next to "Delete Tag" that removes the spool's AMS slot assignment, clearing the location column in the inventory table.
- SpoolBuddy Settings Device Tab No Longer Scrolls — Removed the branding card, folded Device ID into the Device Info card, placed Backend/Auth config and diagnostic buttons side by side in a 2-column layout, removed the redundant online/offline status row from Device Info, and tightened spacing throughout. The Device tab now fits on the small SpoolBuddy touchscreen without scrolling.
- Spool Notes in Assign Spool Modal (#793) — Spool cards in the Assign Spool modal now show the spool's note as a hover tooltip, making it easier to identify spools by tracking IDs or other metadata stored in notes. Works with both internal inventory and Spoolman-synced spools. Requested by @LegionCanadian.
- WiFi Safeguard for SpoolBuddy Pi — The install script now drops an APT hook (
/etc/apt/apt.conf.d/80-preserve-wifi) that backs up NetworkManager WiFi connections before everyapt upgradeand restores them if they get wiped. Prevents headless SpoolBuddy Pis from losing WiFi connectivity after Raspberry Pi OS package upgrades (observed with Bookworm kernel/raspi-config updates that clear/etc/NetworkManager/system-connections/). - SpoolBuddy Install Script Now Upgrades System Packages — The install script now runs
apt-get upgrade -yafter installing required packages and the WiFi safeguard. This ensures the Pi is fully up to date before SpoolBuddy is deployed, and the WiFi safeguard protects connectivity during the upgrade. - SpoolBuddy Assign-to-AMS Material Mismatch Warnings — The SpoolBuddy "Assign to AMS" modal now warns when the spool's material or slicer profile doesn't match the target slot's current filament. Shows a confirmation dialog with five warning levels: exact material mismatch, partial material match, profile-only mismatch, and combined material+profile mismatches. Respects the global
disable_filament_warningssetting. Previously, assigning a spool to an occupied slot proceeded without any validation, matching the behavior already present in the main Assign Spool modal. - Spool Assignment Changes Sync Across Tabs — Assigning or unassigning a spool now broadcasts a WebSocket event to all connected clients. Other open browser tabs and the SpoolBuddy frontend update automatically without requiring a page reload.
- SpoolBuddy Inventory Page — Added a new Inventory page to the SpoolBuddy kiosk UI, accessible from the bottom navigation bar between Write and Settings. Shows a responsive catalog grid of spools with colored spool circles (matching AMS page style), material/subtype labels, color dots, fill level bars, remaining weight with percentage, and green AMS location badges (A1, B2, etc.) for assigned spools. Includes a search bar (filters by material, subtype, brand, color, notes) and touch-friendly inline filter pills ("All", "In AMS", per-material). Tapping a spool opens a full-screen detail view with spool icon, remaining bar, AMS assignment, weight breakdown, slicer filament, PA K-profiles (name and value), temperature range, cost, tag ID, and notes. Detail view updates live from query data. Assigned spools sort first. When Spoolman is enabled, the page shows the Spoolman UI instead.
- SpoolBuddy Auto-Navigate on Tag Scan — When an NFC tag is detected while the SpoolBuddy UI is on a non-dashboard page (Settings, AMS, Write Tag, etc.), the frontend automatically navigates back to the main dashboard to show the scanned spool. Also wakes the screen if the display was blanked.
- SpoolBuddy Swipe to Switch Printers — Swiping left/right on the SpoolBuddy touchscreen now cycles through online printers instead of triggering browser back/forward navigation. The selected printer updates in the top bar dropdown. Requires at least two online printers; single-printer setups are unaffected.
- SpoolBuddy Virtual Keyboard Layout Fix — The virtual keyboard now participates in the flex layout instead of overlaying as a fixed element. When the keyboard opens, the bottom nav and status bar are hidden and the content area shrinks to fit, eliminating the dead space gap between content and keyboard on the Inventory page. Number inputs (e.g. Weight field on Write Tag) now accept virtual keyboard input.
- Removed Diagnostic Buttons from Write Tag Page — Removed the "NFC Diag" and "Scale Diag" buttons from the NFC status panel on the Write Tag page. These diagnostics are accessible from the Settings page and don't belong on the tag writing flow.
- SpoolBuddy Assign Spool Modal No Longer Clips Display — The shared Assign Spool modal overflowed off-screen on the small SpoolBuddy touchscreen, hiding the footer buttons. Added scoped CSS in the SpoolBuddy AMS page that caps the modal at 90vh with a scrollable spool list, without affecting the main Bambuddy frontend.
- SpoolBuddy System Tab — Added a "System" tab to SpoolBuddy Settings showing live OS stats from the Raspberry Pi: CPU temperature, core count, load average, memory usage, disk usage, OS distro/kernel/architecture, Python version, and system uptime. Stats are collected by the daemon every heartbeat (10s) using stdlib-only reads from
/procand/sys— no additional dependencies required. Usage bars turn amber at 70% and red at 90%; CPU temperature is color-coded green/amber/red. - SpoolBuddy Boot Splash Polished — New splash image displays only the SpoolBuddy logo (removed Bambuddy branding) with green glow bloom, radial gradient background, light rays, and vignette. A generator script (
generate_splash.py) is included for easy customization. Also reduced redundant initramfs rebuilds during install by deferring the rebuild until after the Plymouth theme is configured.
Security
- Token-Based Auth for Media Endpoints — Camera streams, snapshots, thumbnails, timelapse videos, photos, QR codes, and cover images served via
<img>/<video>tags now require a stream token query parameter (?token=xxx) when authentication is enabled. Previously these endpoints were unauthenticated because browser media elements cannot sendAuthorizationheaders. The frontend obtains a 60-minute reusable token viaPOST /printers/camera/stream-token(requiresCAMERA_VIEWpermission) and automatically appends it to all media URLs. Affects endpoints in camera, archives, library, printers, print-log, and external-links routes. When auth is disabled (default for local installs), behavior is unchanged — no token required.
Fixed
- Native Install Misdetected as Docker in LXC Containers — The update check falsely identified native installs as Docker when running inside Proxmox LXC containers. The detection logic used
.git/directory absence as a Docker fallback, but LXC containers may also lack.git/depending on how the install was deployed. Replaced the.git/fallback with a proper check of/run/systemd/containerwhich only matches Docker/Podman/OCI runtimes, not LXC. Native installs in LXC containers now correctly show the in-app update button instead of Docker Compose instructions. - Print Fails on Files With Spaces in Name (#824) — Printing files with spaces in their filename (e.g. "Junktion Box PRO 90.3mf") caused the printer to silently ignore the print command and remain IDLE. The FTP upload succeeded, but the MQTT print command's
urlfield (ftp://file name.3mf) contained unencoded spaces that the firmware couldn't parse. Fixed by replacing spaces with underscores in the remote filename before upload. - SpoolBuddy Low Filament Warning Missing Slot Number — The status bar low filament warning showed "AMS B" instead of the specific slot like "B2". Now uses
formatSlotLabelto display the full slot label (e.g. "Low Filament: PLA (B2) - 4% remaining"). - SpoolBuddy Read Tag Diagnostic Fails on NTAG Tags — The
read_tag.pydiagnostic script had five issues preventing NTAG reads: (1) SAK0x04(MIFARE Ultralight family) was rejected as "unsupported tag type" — now accepts both0x00and0x04. (2)ntag_read_pageshad TX CRC off (should be on per NTAG spec), no Crypto1 clear, and no IDLE→TRANSCEIVE state reset. (3) The PN5180 enters an unrecoverable state after an NTAG READ command — added full GPIO hardware reset between each 4-page batch. (4) Reading past the end of smaller tags (MIFARE Ultralight has 16 pages vs NTAG's 44+) caused a hard failure — now returns partial data gracefully. (5)ntag_write_page/ntag_write_pageshad the same stale CRC/state issues plus unreliable ACK checking and post-write verification — synced with daemon. - Delete Tag Leaves Stale Tag Type — The "Delete Tag" button in the spool edit modal only cleared
tag_uidbut lefttray_uuid,tag_type, anddata_originintact. All tag-related fields are now cleared together. - SpoolBuddy NFC Write Fails on NTAG Tags — Multiple issues prevented writing to NTAG 213/215/216 tags. (1) Some chips report SAK
0x04(MIFARE Ultralight family) instead of0x00during anticollision — both0x00and0x04are now accepted. (2) TX CRC was disabled for NTAG commands but the spec requires it — enabled for both WRITE and READ. (3) The PN5180 state machine needed IDLE→TRANSCEIVE resets (not justset_transceive_mode()) and Crypto1 cleared before NTAG operations. (4) The 4-bit WRITE ACK cannot be captured by the PN5180 (SOF detected but no RX_IRQ) — removed per-page ACK checking. (5) Post-write read-back verification also failed (second READ command gets no response from the PN5180) — removed verification since the tag reliably ACKs each write. - Database Connection Pool Exhaustion on Large Printer Farms — Users with 100+ printers connected simultaneously experienced
QueuePool limit of size 10 overflow 20 reached, connection timed outerrors. Increased the SQLAlchemy connection pool from 30 total (10 base + 20 overflow) to 220 (20 base + 200 overflow), and raised the SQLite busy_timeout from 5 to 15 seconds to reduce write contention under heavy concurrent MQTT updates. - SpoolBuddy Update Check Always Shows "Up to Date" — The SpoolBuddy daemon update check compared the device's firmware version against GitHub releases instead of the running Bambuddy backend version. This meant the check could incorrectly report "up to date" even when the daemon was behind. Fixed by comparing directly against
APP_VERSIONfrom the backend config. - SpoolBuddy Updates Now Use SSH — Replaced the fragile self-update mechanism (daemon pulls its own code via git, permission errors on
.git/, hardcodedmainbranch) with SSH-based updates driven by the Bambuddy backend. Bambuddy now SSHes into the SpoolBuddy Pi and runs git fetch/checkout, pip install, systemctl restart, and kiosk browser restart remotely. Updates automatically use the same branch as Bambuddy. SSH key pairing is fully automatic — Bambuddy generates an ED25519 keypair and includes the public key in the device registration response; the daemon deploys it toauthorized_keyson first connect. The install script creates thespoolbuddyuser with a bash shell and sudoers entries for daemon and kiosk restart. A "Force Update" button allows re-deploying even when versions match. The SSH public key is also shown in SpoolBuddy Settings → Updates → SSH Setup for manual pairing if needed. - Frontend Not Updating After Deploy — The service worker used stale-while-revalidate for JS/CSS assets, serving the old cached bundle even after a new build was deployed. Changed to network-first for JS/CSS (Vite content-hashes filenames so cache-busting is built in), bumped SW cache version, and added
Cache-Control: no-cacheto thesw.jsendpoint so browsers always pick up new service worker versions immediately. The SpoolBuddy kiosk now skips SW registration entirely and unregisters any existing SW — a touchscreen kiosk has no use for offline caching and it was the main source of stale frontend issues after updates. - SpoolBuddy Kiosk Starts Before Network Is Ready — On fresh installs, the kiosk browser launched before the network was fully up, showing a connection error for 10-15 seconds until connectivity was restored. The getty@tty1 autologin override now waits for
network-online.targetso Chromium has connectivity when it starts. - SpoolBuddy Update UI Stale After Restart — After a SpoolBuddy update, the UI permanently showed the old version and "update available" because: (1) the SSH update set status to
"complete"after the daemon had already re-registered, overwriting the cleared state; (2) the kiosk restart navigated away from the updates page; (3) query cache served stale data. Fixed by letting daemon re-registration clear all update status, removing the kiosk restart in favor of a frontend-drivenwindow.location.reload()triggered via WebSocket when the daemon comes back online, and adding proper loading states to Check/Force Update buttons. - Virtual Printer Proxy A1 Printing Fails (#757) — BambuStudio could not send prints to A1 (and potentially P1S) virtual printers in proxy mode. The slicer connects to undocumented proprietary ports 2024-2026 on these models, which the proxy was not forwarding, causing BambuStudio to show an access code dialog instead of printing. Added transparent TCP pass-through proxying for ports 2024-2026. These ports are silently ignored on models that don't use them (X1C, H2C, P2S). Also added ports 2024-2026 to the docker-compose.yml bridge-mode port mapping.
- Spool Assignment on Empty AMS Slots (#784) — Empty AMS slots (no physical spool detected) showed "Assign Spool" and "Configure" buttons in the hover popup. Assigning a spool to an empty slot created a stuck state because no "Unassign" button is available for empty slots. Truly empty slots now hide both buttons, while slots with a spool inserted but filament not loaded still show configure/assign. Also fixed stale AMS slot data on H2D and other printers that only send
{id, state}in incremental MQTT updates — filament load/unload transitions now update in real-time without requiring a reconnect. - Spoolman Sidebar Opens Root URL Instead of Spool Page — When Spoolman is enabled, clicking the Filament sidebar item embedded Spoolman at its root URL instead of the spool management page. The iframe now navigates to
<spoolman_url>/spool. - Log Flood: "State is FINISH but completion NOT triggered" (#790) — A diagnostic log message introduced in 0.2.2.1 fired on every MQTT update while a printer sat in FINISH or FAILED state, flooding logs with thousands of lines per minute in printer farms. Fixed by only logging once on the initial state transition, and marking
_completion_triggered = Truewhen a terminal state is first seen without a prior RUNNING state so the flag is clean for the next print cycle. - H2D External Spool Print Fails With "Failed to get AMS mapping table" (#797) — Printing from an external spool on H2D (and H2D Pro) through Bambuddy failed with
0700_8012 "Failed to get AMS mapping table", while the same print worked fine from BambuStudio. Bambuddy was passing raw virtual tray IDs (254/255) in the flatams_mappingarray, but BambuStudio converts these to -1 and relies onams_mapping2for external spool routing. The H2D firmware rejects raw 254/255 in the flat array. Also fixed theams_mapping2format for external trays — each virtual tray is its own AMS unit withslot_id: 0, not a shared unit differentiated by slot. - SpoolBuddy Scale First Reading Always Wrong — The NAU7802 ADC always returns a stale max-scale value (
0x7FFFFF) on its first conversion after power-up, which polluted the moving average and made the initial weight report wildly inaccurate. Fixed by flushing the first reading duringinit()so all subsequent reads return valid data. Also extracted both hardware drivers out of diagnostic scripts into proper modules — the NAU7802 scale driver fromscripts/scale_diag.pyintodaemon/nau7802.py, and the PN5180 NFC driver fromscripts/read_tag.pyintodaemon/pn5180.py. The production daemon was importing driver classes from test scripts since the original SpoolBuddy commit. Removed the now-unnecessarysys.pathhack frommain.py. - ffmpeg Process Leak Causing Memory Growth (#776) — Camera stream ffmpeg processes accumulated over time, consuming several GB of RAM. When a user closed the camera viewer, the frontend sent a stop signal that killed the ffmpeg process, but the backend stream generator interpreted the dead process as a dropped connection and respawned ffmpeg — up to 30 reconnection attempts per stream. The orphan cleanup couldn't catch these because they were tracked as "active". Fixed by signaling the generator's disconnect event from the stop endpoint before killing the process, checking for stream removal before reconnecting, and tracking frame timestamps per-stream instead of per-printer so stale detection works correctly when multiple streams exist. Reported by @ChrisTheDBA,
[0.2.2.1] - 2026-03-22
New Features
- SpoolBuddy OTA Updates — SpoolBuddy devices can now be updated directly from the Settings → Updates tab without SSH access. Click "Check for Updates" to see if a newer version is available, then "Apply Update" to trigger the update. The daemon picks up the command via its heartbeat, pulls the latest code from GitHub, installs dependencies, and restarts automatically via systemd. Live progress is shown in the UI with status messages from the device. The status bar at the bottom automatically checks for updates every 5 minutes and shows a prominent message when one is available. Requires the device to be online.
- Select Plates to Queue (#777) — Multi-plate 3MF files now support selecting a subset of plates to queue, instead of only "one plate" or "all plates". In add-to-queue mode, each plate has a checkbox for multi-select, with a "Select All / Deselect All" toggle. Reprint and edit modes remain single-select. Requested by @stringham.
- Camera Image Rotation (#672) — Added per-printer camera rotation (0°, 90°, 180°, 270°) for cameras mounted in portrait or upside-down orientations. Configurable in Settings → Camera for each printer. Rotation applies to live stream, embedded viewer, stream overlay, and notification snapshots. Requested by @wrenoud.
- Per-User Email Notifications (#693) — When Advanced Authentication is enabled, individual users can now receive email notifications for their own print jobs. A new "Notifications" page lets each user toggle notifications for print start, complete, failed, and stopped events. Only prints submitted by that user trigger their email — other users' prints are not affected. Requires SMTP to be configured and the "User Notifications" toggle enabled in Settings → Notifications. Administrators and Operators have access by default; Viewers do not. Contributed by @cadtoolbox.
Fixed
- SpoolBuddy Daemon Reports Stale Version — The SpoolBuddy daemon maintained its own hardcoded
__version__that was never bumped to0.2.3b1, causing the update check to incorrectly show an update from0.2.2b1to the latest release. Fixed by reading the version at import time from the backend'sAPP_VERSIONinbackend/app/core/config.py— the single source of truth — so the daemon version is always in sync. - SpoolBuddy Update Columns Missing from Database — The OTA update feature added
update_statusandupdate_messageto the device model but was missing the database migration, causing "no such column" errors on existing installations. - Queue Print Command Not Reaching Printer (#778) — When a queue item targeted a specific printer and the scheduler's power-on-wait loop triggered, each reconnection attempt created a new MQTT client that re-attempted subscribing to the request topic. On printers whose broker rejects this subscription (e.g. A1), this caused repeated connect/disconnect cycles for up to 170 seconds, leaving the MQTT connection in a fragile state where the print command could silently fail to reach the printer. Fixed by caching request topic support state per serial number at the class level, so new client instances skip the subscription immediately instead of rediscovering the rejection. Reported by @RubenKremer.
- Stale MQTT Connection Not Recovering (#813) — When a printer's MQTT connection went stale (no messages for 60+ seconds), Bambuddy marked it as disconnected but did not force the underlying TCP socket closed, so paho-mqtt's auto-reconnect never triggered and print commands were silently published into a dead connection. Fixed by force-closing the socket on stale detection so paho's loop thread detects the break and auto-reconnects. The initial fix caused rapid connected/disconnected bouncing in the UI because frontend status polls triggered repeated socket force-closes before paho could finish reconnecting; added a 30-second cooldown between stale reconnect attempts so paho has time to re-establish the connection. Also uses a flag to suppress the redundant disconnect callback broadcast. Relaxed MQTT keepalive from 15s to 30s — the aggressive 15s keepalive caused spurious disconnects on transient network hiccups. Added reconnect backoff (1-30s) and unique-per-process MQTT client IDs to prevent broker session takeovers. Error disconnects (
rc.is_failure) are never suppressed by the spurious-disconnect filter. The disconnect event used bydisconnect()is fired unconditionally at the top of the callback so that no early-return filter can prevent it from unblocking callers. Reported by @inkdawgz. - P1S/P1P Printer Card Shows "Printing" When Idle (#813) — Some P1S and P1P firmware versions report
stg_cur=0when idle, which maps to the "Printing" stage name and overrides the correct "Idle" gcode_state on the printer card. The System Info page was unaffected because it displays the raw gcode_state. Extended the existing A1/A1 Mini workaround for this firmware bug to also cover P1S and P1P models. Reported by @inkdawgz. - AMS Slot Search Shows Unrelated Profiles (#681) — Searching for a non-existent filament profile in the AMS slot configuration showed unrelated profiles instead of an empty result. The saved preset bypassed the search filter entirely, so stale mappings (e.g. a slot previously configured with "Bambu PLA Matte" that now holds a Silk spool) would always appear regardless of the search query. The saved preset now only bypasses the printer model filter, not the search filter. Reported by @RosdasHH.
- Virtual Printer FTP Routed to Wrong VP (#735) — When running multiple virtual printers with different access codes on separate bind IPs, FTP connections were routed to the wrong VP. Root cause: the iptables
REDIRECTrule rewrites the destination IP to the incoming interface's primary address, so all FTP traffic went to the first VP regardless of the intended target. Fix: FTP server now binds directly to port 990 (standard implicit FTPS), eliminating the need for iptables redirect. RequiresCAP_NET_BIND_SERVICE(already set in the systemd service and Docker image). Also removed a globalset_exception_handler()in the MQTT server that caused spurious error messages when running multiple VPs. Seedocs/migration-vp-ftp-port.mdfor migration steps. Reported by @VREmma. - X1C Virtual Printer Not Accepting Sends (#735) — X1C (and X1) virtual printers were advertised with legacy SSDP model codes (
3DPrinter-X1-Carbon/3DPrinter-X1) that BambuStudio doesn't recognize, causing "incompatible printer preset" when sending. Fixed to use the correct codes (BL-P001/BL-P002). Also fixed proxy mode auto-inherit storing the printer's display name (e.g.X1C) instead of the SSDP code. Existing VPs are automatically migrated on startup. Reported by @RosdasHH. - White Filament Color Swatches Invisible in Light Theme (#726) — Filament color circles used a white border that was invisible against light theme backgrounds, making white spools indistinguishable. Changed to a dark border (
border-black/20) across all views: Inventory, Archives, Assign Spool, Configure AMS Slot, Calendar, Projects, Filament Trends, Local Profiles, Link Spool, and Spoolman Settings. Reported by user. - Camera Window Overlapping Modals (#738) — Floating camera viewer rendered on top of modals (e.g. Assign Spool), making them unusable. Lowered camera z-index so modals always appear above it. Reported by @maziggy.
- Print Complete Notification Not Firing (#736) — Print complete notifications could silently fail if the finish photo capture hung or timed out, because the notification was chained behind the photo task with no timeout. Added a 45-second timeout so notifications always send even if photo capture stalls. Also added diagnostic logging for MQTT state detection to trace completion triggers. Reported by @piatho.
- Webhook Notifications Missing Camera Snapshot (#679) — Webhook notification providers did not include camera snapshots (e.g. from First Layer Complete notifications), even though providers like Telegram, Pushover, ntfy, and Discord already attached them. The webhook payload now includes a base64-encoded
imagefield when a snapshot is available (generic format only, not Slack format). Reported by @Arn0uDz. - Mobile Sidebar Not Scrollable — On mobile devices with many navigation items, the sidebar did not scroll, making bottom items unreachable. Added overflow scrolling to the nav section while keeping the logo and footer pinned.
- User Notification Ruff/Lint Fixes (#693) — Fixed missing
timezoneimport in email timestamp, unused lambda argument, PEP 8 blank line spacing formark_printer_stopped_by_user, and SQLAlchemy forward reference inUserEmailPreferencemodel. - Carbon Rod Lubrication Maintenance Task Incorrect (#755) — X1/P1 series printers showed a "Lubricate Carbon Rods" maintenance task, but carbon rods use plain bearings and should never be lubricated — doing so degrades print quality. Removed the lubrication task; only "Clean Carbon Rods" remains. Existing "Lubricate Carbon Rods" entries are automatically removed on next startup. Reported by @RosdasHH.
- Ntfy Notifications Fail With Non-ASCII Characters (#742) — Ntfy notifications with camera snapshots failed when the printer name or filename contained non-ASCII characters (e.g. accented letters, CJK). The
TitleandMessageHTTP headers were passed as Python strings, causing httpx to reject them withUnicodeEncodeError. Fixed by encoding header values as UTF-8 bytes, which ntfy handles correctly. Test notifications were unaffected because they use a hardcoded ASCII title and no image attachment. Reported by @user. - Virtual Printer Proxy Mode Printing Fails on Isolated Networks (#757) — When the slicer and printer are on different VLANs/subnets, Bambu Studio could not send prints through the virtual printer proxy because: (1) the printer's real IP leaked through MQTT payloads (
rtsp_url,net.info[].ip), causing BS to bypass the proxy; (2) the bind/detect protocol (port 3000/3002) was forwarded to the real printer, leaking its identity and name; (3) the file transfer tunnel (port 6000) used by BS for verify_job and uploads was not proxied; (4) FTP data connections for zero-byte uploads (verify_job) failed due to a TLS handshake race condition. Fixed by: rewriting IP addresses in MQTT PUBLISH payloads (both string and integer formats) with proper MQTT framing preservation, responding to bind/detect with the VP's own identity via BindServer, adding transparent TCP proxies for port 6000 (file transfer) and port 322 (RTSP camera), buffering slicer data during FTP data proxy connection setup, and advertising the configured VP name in SSDP. Also added cross-subnet SSDP support via a wildcard listener for VPN/multi-subnet setups. Reported by @Utility9298. - Virtual Printer Proxy Mode X1C/X1 Print Upload Fails (#757) — X1C and X1 printers failed to upload prints through proxy mode. After FTP verify_job succeeded (226), BambuStudio's closed-source
bambu_networkingDLL silently refused to proceed with the actual 3MF upload, showing a login modal instead. Root cause: the DLL validates the TLS connection parameters and rejects connections where the certificate doesn't match the printer's real BBL CA certificate. The TLS-terminating proxy presented Bambuddy's own "Virtual Printer CA" certificate, which the DLL rejected. Fixed by switching to transparent TCP proxying for FTP (port 990), FileTransfer (port 6000), Camera (port 322), and FTP passive data (ports 50000–50100) — raw bytes are forwarded without TLS termination, so the slicer gets end-to-end TLS directly with the printer's real certificate. Only MQTT (port 8883) remains TLS-terminated, which is required to rewrite the printer's real IP with the proxy's bind IP in MQTT payloads. Confirmed working on both H2D and X1C printers. - UserEmailPreference Model Not Registered — The
UserEmailPreferenceSQLAlchemy model was not imported inmodels/__init__.py, causing mapper initialization failures when theUsermodel's relationship resolved the string reference before the model class was registered with Base metadata. - Native Install Missing CAP_NET_BIND_SERVICE — The
install.shsystemd service template was missingAmbientCapabilities=CAP_NET_BIND_SERVICE, causing Virtual Printer proxy mode to silently fail to bind privileged ports (322, 990) on native installations. - Virtual Printer Proxy A1 Diagnostics (#757) — Added diagnostic port probing (ports 21, 80, 443) on proxy VP bind IPs to detect if BambuStudio tries to connect on ports the proxy doesn't handle. Logs a warning when an unexpected connection is detected. Helps diagnose A1/A1 Mini proxy issues where the slicer may use a different connection flow.
- File Rename Removes Extension (#751) — Renaming a file in the File Manager included the file extension in the editable text, so users could accidentally remove it (e.g. renaming
bracket.gcode.3mftobracket), making the file unprintable. The rename modal now only lets users edit the base name, with the extension shown as a non-editable suffix. Reported by @fleishmaab, confirmed by @cadtoolbox. - Spurious "Job Waiting for Filament" Notification (#753) — When all printers of a model were busy and a job was queued with ASAP timing, a "Job Waiting for Filament" notification fired immediately even though no filament issue existed. The job was simply waiting for a printer to finish. The scheduler now skips the waiting notification when all matching printers are just busy, since the job will auto-start when one finishes. Also renamed the default notification title from "Job Waiting for Filament" to "Queue Job Waiting" to accurately reflect all waiting reasons. Reported by @maziggy.
- AMS Spools Removed After Printer Restart (#765) — AMS spool assignments and slot configurations were lost after restarting the printer. When the printer shuts down, it sends a final MQTT message with
tray_exist_bits=0andpower_on_flag=false, which caused Bambuddy to clear all AMS slot data and auto-unlink every spool assignment. On reconnect, the assignments were gone. Fixed by skippingtray_exist_bitsslot clearing whenpower_on_flagisfalse(shutdown message), preserving AMS data across printer restarts. Reported by @Woyteck1.
Community Contributions
- Admin Set Default Nav-Menu Order (#761) — Admins with authentication enabled can now set their current sidebar menu order as the default for new users. New users inherit this layout on first login and can customize it afterward. Contributed by @cadtoolbox.
- Improve Home Assistant Notifications (#750) — Added support for Home Assistant
notifyservices in addition to the existing REST-based integration. Contributed by @mrtncode. - Add Total Cost to Projects (#733) — The Projects page now shows a total cost that sums material, energy, and BOM costs. Contributed by @Keybored02.
- Material Mismatch & Insufficient Filament Checks (#720) — When assigning non-Bambu Lab spools, a warning prompts if the filament type or profile doesn't match. Pre-print checks now also warn when the spool has insufficient material. Both warnings are dismissible, with a toggle in Settings. Contributed by @Keybored02.
- Send Bambu RFID Tags to Spoolman & Manual Mode Unlink (#719) — Bambu Lab spool RFID identifiers (tray UUID) are now sent to Spoolman instead of generic placeholder tags. An "Unlink" button appears on Bambu spools when Spoolman is in manual sync mode. Fixed location clearing for generic spools during sync. Contributed by @shrunbr.
- Rework Archive Duplicates Tagging (#718) — Duplicate detection now requires both matching filename and SHA256 hash. The tag shows reprint count instead of "Duplicate" text, links back to the parent print, and a new "Hide Duplicates" filter is available. Contributed by @Keybored02.
Added
- Quick Print Speed Control (#256) — Added a print speed control badge to the printer card controls row, next to the fan status badges. Click to choose between Silent (50%), Standard (100%), Sport (124%), and Ludicrous (166%) speed presets. The badge shows the current speed percentage with a gauge icon, always visible but disabled when no print is active. Includes optimistic UI updates for instant feedback. Requested by @Sllepper.
- Spool Rotation During AMS Drying — Added a "Rotate spool during drying" checkbox to the manual drying popover for AMS 2 Pro and AMS-HT units. Rotates the spool for more even heat distribution. Off by default; resets when opening the popover for a different AMS unit. The firmware silently disables rotation if filament is currently loaded from the unit.
- Spool Name Column & Filter in Filament Inventory (#740) — Added a "Spool" column to the filament inventory table that displays the spool catalog entry name (e.g. "Bambu Lab AMS Tray", "Sunlu 1kg"). Enable it via the column visibility menu. Sortable and hidden by default. Also added a spool name filter dropdown next to the brand filter for quick filtering by spool type. Requested by @DMoenning.
Changed
- Redesigned Bug Report Debug Log Flow — Replaced the fixed 30-second debug log collection with an interactive 3-step flow: start debug logging, reproduce the issue at your own pace, then stop & submit. An elapsed timer shows recording duration with auto-stop at 5 minutes. Users now have full control over when to capture logs instead of racing a countdown. The backend splits log collection into separate start/stop endpoints, and the frontend shows a step progress indicator with pulsing active state.
Improved
- HMS Error Visibility on Printers Page (#772) — Improved visibility of printers with HMS errors for large print farms. Added a red "Problem" counter to the status summary bar showing how many connected printers have active HMS errors. The compact-mode status pip (colored dot) now turns red for fatal/serious errors (severity ≤ 2) or amber for common warnings, instead of only showing connection status. Progress bars turn amber when a print is paused. Sorting by status now places printers with HMS errors at the top, above printing and idle printers. Requested by @jimmy-brightz.
- Print Command Response Verification (#737) — After sending a print command, BambuBuddy now monitors whether the printer's state changes within 15 seconds. If the printer silently ignores the command (observed on some P1S firmware versions where the MQTT command handler becomes unresponsive), a warning is logged for diagnostics. This aids debugging when users report prints not starting despite BambuBuddy showing success.
- Compact Assign Spool Modal (#725) — The "Assign Spool" modal now uses a compact 3-column grid layout instead of a vertical list, showing more spools at once without scrolling. Each card displays the spool name, color, and remaining/total weight. The modal is wider with a taller scroll area. Requested by @RosdasHH.
- Reformatted AMS Drying Presets Table (#732) — The drying presets table in Settings now groups columns by AMS type (AMS 2 Pro, AMS-HT) with inline °C and h unit labels next to each input, replacing the previous flat column layout. Requested by @cadtoolbox.
Security
- Bump pyOpenSSL 25.3.0 → 26.0.0 — Fixes CVE-2026-27448 (exception swallowing in TLS servername callback) and CVE-2026-27459 (buffer overflow in DTLS cookie callback).
- Bump pyasn1 0.6.2 → 0.6.3 — Fixes CVE-2026-30922 (stack overflow from deeply nested ASN.1 structures).
- Bump flatted 3.4.1 → 3.4.2 — Fixes GHSA-rf6f-7fwh-wjgh (prototype pollution via
parse()). Dev-only dependency (eslint).
[0.2.2] - 2026-03-16
New Features
- First Layer Complete Notification (#679) — Get notified with a camera snapshot when the first layer finishes printing, so you can check adhesion remotely without watching the whole print. Enable the "First Layer Complete" toggle on any notification provider. Fires once per print when layer 2 begins (confirming layer 1 is done), with a guard against spurious triggers on printer reconnect. Requested by community.
- Remote AMS Drying (#292) — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page. A flame icon appears on supported AMS cards; clicking it opens a popover to select filament type (PLA, PETG, TPU, ABS, ASA, PA, PC, PVA) with official BambuStudio temperature/duration presets, or set temperature manually. When drying is active, a status bar shows the time remaining with a live countdown and stop button. Supported on X1/X1C (fw 01.09+), P1P/P1S (fw 01.08+), H2D (fw 01.02.30+), H2D Pro, and X1E. Not supported on P2S, A1, A1 Mini, H2S, or H2C. Requires
printers:controlpermission when authentication is enabled. - Queue Auto-Drying (#292) — Automatically dry filament between scheduled queue prints. When enabled in Settings → Print Queue, the scheduler starts drying on idle printers that have upcoming scheduled prints and whose AMS humidity exceeds the configured threshold. Uses conservative parameters (lowest temperature, longest duration) when mixed filament types are loaded. Drying stops automatically when humidity drops below threshold (with a 30-minute minimum to prevent oscillation), when scheduled items are removed, or when the feature is disabled. Optional "block queue" mode delays the next print until drying completes.
- Configurable Drying Presets (#292) — Customize temperature and duration for each filament type in Settings → Print Queue. Defaults match BambuStudio presets (PLA 55°C/8h, PETG 65°C/8h, etc.) and are used by both the manual drying popover and queue auto-drying. AMS 2 Pro and AMS-HT use separate presets reflecting their different heating capabilities.
- AMS PSU Detection (#292) — The drying button is disabled with a tooltip when the AMS lacks sufficient power for drying (e.g. not connected to the external PSU). Reads
dry_sf_reasonfrom printer firmware and surfaces HMS error codes for AMS 2 Pro and AMS-HT power issues. - Ambient Drying (#292) — Automatically keep filament dry on idle printers based on humidity, even without queued prints. Enable "Ambient drying" in Settings → Print Queue to have the scheduler start drying on any idle printer whose AMS humidity exceeds the configured threshold — no scheduled prints required. Uses the same humidity threshold, drying presets, and power constraint detection as queue auto-drying. Both modes can be enabled simultaneously. Requested by community.
- Assign Spool to Empty AMS Slot (#717) — Previously, the "Assign Spool" button only appeared on AMS slots that already had a filament profile configured, requiring users to first configure the slot manually before assigning an inventory spool — even though the assignment auto-configures the slot anyway. The "Assign Spool" option now appears on empty (unconfigured) slots as well. Selecting a spool auto-configures the slot with the correct filament profile, color, and K-profile in one step. Also fixed the AMS slot profile label showing the generic material type (e.g. "PLA") instead of the spool's actual slicer preset name (e.g. "PolyLite PLA Pro") after assignment. Requested by @RosdasHH.
- Home Assistant Notification Provider (#656) — Added Home Assistant as a notification provider. When HA is configured in Settings → Network → Home Assistant, selecting "Home Assistant" as a notification provider sends persistent notifications to the HA dashboard — no additional configuration needed. From there, HA automations can forward notifications to mobile apps, WhatsApp, or any other service. Requested by @TravisWilder.
- Virtual Printer Queue Auto-Dispatch Toggle (#587) — Added an "Auto-dispatch" toggle to virtual printers in Queue mode. When enabled (default), prints sent from the slicer are added to the queue and start automatically on the assigned printer — matching the current behavior. When disabled, prints are added to the queue with
manual_startset, so they wait for manual dispatch. This allows users who want to review and manually assign prints before they start. Requested by @Percy2Live. - Queue All Plates (#530) — Multi-plate 3MF files can now be queued in one action. When adding a multi-plate file to the queue, a "Queue All N Plates" toggle appears in the plate selector. When activated, every plate is added as a separate queue entry (one per plate × per selected printer), each individually editable from the queue page. The toggle is only available in add-to-queue mode (not reprint or edit). Requested by @Dendrowen.
- Malaysian Ringgit Currency (#634) — Added MYR (RM) to the list of supported currencies for filament cost tracking. Requested by @cynogen127.
- ETA Variable in Notifications (#638) — Added
{eta}template variable to print start, print progress, and queue job started notifications. Shows the estimated wall-clock completion time (e.g. "15:53" or "3:53 PM") based on the user's configured time format (12h/24h). Existing{estimated_time}still shows duration ("1h 23m"). Requested by @SebSeifert. - Bulk Delete Spool and Color Catalog Entries (#646) — Added checkbox selection and bulk delete to both the Spool Catalog and Color Catalog in Settings > Filament. Select individual entries with checkboxes, use the header checkbox to select/deselect all visible entries, then click "Delete Selected" to remove them in one operation. Previously, entries could only be deleted one at a time. Requested by @SebSeifert.
- Force Color Match (#625) — Added a "Force Color Match" option for "Print to Any" queue scheduling. When enabled, the scheduler requires a strict color match when assigning prints to printers, preventing incorrect filament assignments when multiple candidates are close in color. Prints wait in the queue until a printer with the exact matching filament is available. Contributed by @cadtoolbox.
- Israeli New Shekel Currency — Added ILS (₪) to the list of supported currencies for filament cost tracking.
- AMS Info Card & Custom Labels (#570) — Hovering an AMS label (e.g. "AMS-A") on the Printers page now shows a popover with serial number, firmware version, and an editable friendly name. Custom labels are stored by AMS serial number so they persist when the unit is moved to a different printer. Slot numbers are now displayed inside each filament color circle with auto-inverted contrast for readability. Labels also appear in the Inventory page's location column. Contributed by @cadtoolbox.
- In-App Bug Reporting — A floating bug report button in the bottom-right corner lets users submit bug reports directly from the Bambuddy UI. Reports include a description, optional screenshot (upload, paste, or drag & drop with automatic JPEG compression), optional contact email, and automatically collected diagnostic data. On submit, the system temporarily enables debug logging, sends push_all to all connected printers, waits 30 seconds to collect fresh logs, then submits everything to a secure relay on bambuddy.cool which creates a GitHub issue with sanitized logs uploaded as a separate file. All sensitive data (printer names, serial numbers, IPs, credentials, email addresses) is redacted from logs before submission. The expandable data privacy notice details exactly what is and isn't collected. Translated into all 7 supported languages.
- SpoolBuddy NFC Tag Writing (OpenTag3D) — SpoolBuddy can now write NFC tags for third-party filament spools using the OpenTag3D format on NTAG213/215/216 stickers. A new "Write" page (
/spoolbuddy/write-tag) in the kiosk UI provides three workflows: write a tag for an existing inventory spool (no tag linked yet), create a new spool and write in one flow, or replace a damaged tag (unlinks old, writes new). The left panel shows a searchable spool list or a compact creation form (material dropdown, color picker, brand, weight); the right panel shows real-time NFC status with tag detection, a spool summary, and the write button. The backend encodes spool data as a 133-byte OpenTag3D NDEF message (MIME typeapplication/opentag3d, fits NTAG213's 144-byte capacity) containing material, color, brand, weight, temperature, and RGBA color data. The write command flows through the existing heartbeat polling mechanism — the frontend queues a write, the daemon picks it up on the next heartbeat, writes page-by-page with read-back verification via the PN5180's NTAG WRITE (0xA2) command, and reports success/failure via WebSocket. On success the tag UID is automatically linked to the spool withdata_origin=opentag3d. Written tags are readable by any OpenTag3D-compatible reader including SpoolBuddy itself. Translations added for all 6 languages. - SpoolBuddy On-Screen Keyboard — Added a virtual QWERTY keyboard for the SpoolBuddy kiosk UI (and login page) since the Raspberry Pi has no physical keyboard and system-level virtual keyboards (squeekboard, wvkbd) don't auto-show/hide in the labwc/Chromium kiosk environment. Uses
react-simple-keyboardwith a dark theme matching the bambu-dark/bambu-green palette. Auto-shows when any text/password/email input is focused, supports shift, caps lock, backspace, and email-friendly keys (@, .). Inputs withdata-vkb="false"are excluded (e.g. SpoolBuddySettingsPage's own numpad). A two-phase close prevents ghost-click passthrough to elements underneath the keyboard. - SpoolBuddy Inline Spool Cards — Placing an NFC-tagged spool on the SpoolBuddy reader now shows spool info directly in the dashboard's right panel instead of a separate modal overlay. Known spools display a SpoolIcon with color/brand/material, a large remaining-weight readout with fill bar, and a weight comparison grid, with action buttons for "Assign to AMS", "Sync Weight", and "Close". Unknown tags show the tag UID, scale weight, and offer "Add to Inventory" or "Link to Spool" actions. The card stays visible if the tag is removed (for continued interaction) and won't re-appear for the same tag after dismissal — but re-placing a tag after removal shows it again. The idle spool animation displays when no tag is detected.
- SpoolBuddy AMS Page: External Slots & Slot Configuration — The SpoolBuddy AMS page (
/spoolbuddy/ams) now displays external spool slots (single nozzle: "Ext", dual nozzle: "Ext-L"/"Ext-R") and AMS-HT units in a compact horizontal row below the regular AMS grid, fitting within the 1024×600 kiosk display without scrolling. Clicking any AMS, AMS-HT, or external slot opens theConfigureAmsSlotModalto configure filament type and color — the same modal used on the main Printers page. Dual-nozzle printers show L/R nozzle badges on each AMS unit. Temperature and humidity are displayed with threshold-colored SVG icons (green/gold/red) matching the Bambu Lab style on the main printer cards, using the configured AMS humidity and temperature thresholds from settings. - SpoolBuddy Dashboard Redesign — Redesigned the SpoolBuddy dashboard with a two-column layout: left column shows device connection status (scale and NFC with state-colored icons — green when device is online, gray when offline) and printer status badges below (compact pills with green/gray dots for online/offline, wrapping to fit without scrolling); right column shows the current spool card. Cards use a dashed border style for a cleaner look. The large weight display card was removed in favor of the inline scale reading in the device card. Unknown NFC tags now offer a quick-add modal that creates a basic PLA spool entry linked to the tag — with a hint recommending users add spools via the main Bambuddy UI first for full details. The separate SpoolBuddy inventory page was removed since inventory management belongs in the main Bambuddy frontend; the bottom nav now has three tabs (Dashboard, AMS, Settings).
- SpoolBuddy Kiosk Auth Bypass via API Key — When Bambuddy auth is enabled, the SpoolBuddy kiosk (Chromium on RPi) was redirected to the login page because the
ProtectedRouterequires a user object fromGET /auth/me, which only accepted JWT tokens. The/auth/meendpoint now also accepts API keys (viaAuthorization: Bearer bb_xxxorX-API-Keyheader) and returns a synthetic admin user with all permissions. The frontend'sAuthContextreads an optional?token=URL parameter on first load, stores it in localStorage, and strips it from the URL to prevent leakage via browser history or referrer. The install script now includes the API key in the kiosk URL (/spoolbuddy?token=${API_KEY}), so the device authenticates automatically on boot without manual login. - Daily Beta Builds — Added a release script (
docker-publish-daily-beta.sh) that reads the currentAPP_VERSIONfrom config, builds a multi-arch Docker image, pushes to both GHCR and Docker Hub, and creates/updates a GitHub prerelease with changelog notes. Daily builds overwrite the same beta version tag (e.g.,0.2.2b1) — users pull the latest by re-pulling the tag or using Watchtower. Beta images are never tagged aslatest. Fixed auto-generated "Contributors" section appearing in GitHub release notes by stripping@mentionsfrom changelog text before creating the release. - Inventory Scale Weight Check Column — Added a "Weight Check" column (hidden by default) to the inventory table that compares each spool's last scale measurement against its calculated gross weight (net remaining + core weight). Spools within a ±50g tolerance show a green checkmark; mismatched spools show a yellow warning with the difference and a sync button that trusts the scale reading and resets weight tracking. The backend stores
last_scale_weightandlast_weighed_aton each spool whenever weight is synced via SpoolBuddy, and the column tooltip shows scale weight, calculated weight, and difference. Edge case: when scale weight is below core weight (empty spool or not on scale), the comparison treats it as a match since sync can't correct this.
Fixed
- Library Upload Doesn't Show New File Until Page Reload (#704) — After uploading a file in the Library file manager, the file list didn't update until the user reloaded the browser. The upload endpoint used
db.flush()instead ofdb.commit(), so the new row was only written to the database after the response was sent to the client. The frontend immediately refetched the file list upon receiving the response, but a new database session couldn't see the uncommitted row — resulting in stale data. Fixed by committing before the response is returned. Also fixed the same race condition in folder create, folder update, and file update endpoints. Reported by @shadowjig. - Printer File Manager Doesn't Auto-Refresh (#704) — The printer file manager (SD card browser) only fetched the file list once when opened. Files uploaded from BambuStudio/OrcaSlicer while the modal was open wouldn't appear until the user clicked the refresh button or reopened the modal. Now auto-refreshes every 30 seconds while open. Reported by @shadowjig.
- Database Connection Pool Exhaustion Under Load (#704) — Background tasks (print scheduler FTP uploads, camera captures, notification sends, timelapse stitching) held database sessions open during slow network I/O, consuming connection pool slots for seconds at a time. With the default pool of 15 connections (size 5 + overflow 10), concurrent operations during print start/complete events could exhaust the pool, causing
QueuePool limit reachederrors andgreenlet_spawnfailures in RFID spool auto-assignment. Doubled the pool to 30 connections (size 10 + overflow 20). Reported by @shadowjig. - Block Mode Skips Humidity Auto-Stop (#292) — When "Wait for drying to complete" was enabled and a printer had pending queue items, the scheduler skipped the humidity auto-stop check entirely. A drying session that reached its humidity target would continue indefinitely instead of stopping after the 30-minute minimum. Now, block mode only prevents starting new drying — already-drying printers still have their humidity checked and stopped when the threshold is met.
- AMS Fill Level Shows 0% for Non-Viewer Users (#676) — When authentication was enabled with advanced permissions, users with
inventory:view_assignmentspermission saw 0% fill level on AMS slots where inventory spool data had staleweight_usedvalues. The fill level fallback chain (Spoolman → Inventory → AMS remain) used nullish coalescing (??), which doesn't fall through on0— so a stale inventory fill of 0% permanently shadowed the correct real-time AMS remain value from the printer. Now, when inventory says 0% but the AMS hardware reports a positive remain, the inventory value is bypassed in favor of the live AMS data. Viewer users were unaffected because their group lackedinventory:view_assignments, so the inventory query never fired and the AMS remain was used directly. Reported by @cadtoolbox. - Virtual Printer Proxy Mode Always Shows X1C Model — Creating a virtual printer in Proxy mode always set the model to X1C regardless of the destination printer, because the frontend hides the model dropdown in proxy mode and the backend defaulted to X1C. Now auto-inherits the model from the target printer when creating or updating a proxy virtual printer (e.g. a proxy pointing at a P1S correctly presents itself as P1S to the slicer). The model also auto-updates when changing the target printer or switching to proxy mode.
- Cloud Profiles Shared Across All Users (#665) — When authentication was enabled, Bambu Cloud credentials were stored globally — one account per Bambuddy instance. If User A logged into Cloud, every other user saw User A's account and profiles. User B logging in would overwrite User A's credentials. Cloud credentials are now stored per-user: each user logs into their own Bambu Cloud account independently. When auth is disabled (single-user mode), behavior is unchanged. Also fixed cloud data endpoints (
/cloud/settings,/cloud/fields, preset CRUD) requiringsettings:read/settings:updatepermissions instead ofcloud:auth— users who had "Cloud Auth" enabled but "Settings" disabled couldn't load profiles after logging in. Reported by @cadtoolbox. - Local Profiles Not Shown in AMS Slot Configuration — Imported local filament profiles were hidden in the AMS slot configure modal when a printer model was set. The
compatible_printersfilter parsed the stored JSON array as a semicolon-delimited string, so the matching always failed and every local preset was silently skipped. Removed the filter entirely — user-imported profiles should be available on any printer. - Interface Aliases Not Shown in Virtual Printer Interface Select — Interface aliases (e.g.
eth0:1) added for multi-virtual-printer setups were invisible in the bind IP dropdown. The Docker image didn't includeiproute2, so theipcommand wasn't available and the code fell back to ioctl-based enumeration which can only return one IP per interface. Addediproute2to the Docker image. - P2S Camera Stream Disconnects After a Few Seconds (#661) — The P2S firmware drops RTSP sessions after a few seconds with an I/O error. Root cause: ffmpeg in the Docker image uses GnuTLS for TLS, and Debian's hardened GnuTLS defaults reject TLS behaviors (renegotiation, legacy ciphers) that some printer firmwares rely on. Added a local TLS termination proxy that uses Python's ssl module (OpenSSL) to handle the TLS connection to the printer, exposing a plain RTSP port to ffmpeg. The proxy rewrites RTSP request-line URLs while preserving Digest auth headers. Also reduced RTSP reconnect delay from 1.0s to 0.2s, added ffmpeg fast-start flags for lower startup latency, and fixed external camera streams being choppy due to double rate-limiting in the proxy layer. Reported by @ddetton, confirmed by @DMoenning.
- iOS/iPadOS Cannot Reposition Floating Camera (#687) — The floating camera viewer (embedded camera window on the dashboard) could not be dragged or resized on iOS/iPadOS because it only handled mouse events. Touch input scrolled the page underneath instead of moving the camera window. Added touch event support (
touchstart/touchmove/touchend) to both the header drag handle and the resize handle, withpreventDefaultto stop page scrolling during drag. Reported by @dsmitty166. - PA-CF / PA12-CF / PAHT-CF Not Treated as Compatible (#688) — Bambu Lab firmware treats PA-CF, PA12-CF, and PAHT-CF as interchangeable, but the print scheduler and filament override UI used exact string matching. If a 3MF required PA-CF but the AMS had PA12-CF loaded, the scheduler wouldn't assign the job and the filament override dropdown was empty/disabled. Added a filament type equivalence system so these PA variants are treated as compatible in scheduler assignment, AMS slot matching, force color match validation, and the filament override dropdown. Reported by @aneopsy.
- Force Color Match Toggle Click Target Too Large (#688) — In the Schedule Print modal, clicking anywhere on the "Force color match" row toggled the checkbox, not just the checkbox and its label. The click target now covers only the checkbox, icon, and label text. Reported by @aneopsy.
- HA Switch Badge Always Sends Turn On Instead of Toggle — Clicking a non-script Home Assistant entity (switch, light, input_boolean) on the printer card always sent
turn_on, which is a no-op when the switch is already on. Now sendstogglefor non-script entities so the badge click actually toggles the switch state. Script entities still useturn_on(stateless trigger). - Multiple Plugs Per Printer Crashes Auto-On/Off — When multiple smart plugs were assigned to the same printer (e.g., a Tasmota plug + an HA switch), the auto-on/auto-off handler called
scalar_one_or_none()which raisesMultipleResultsFound. Now fetches all plugs and returns the main (non-script) power plug, matching the API route behavior. - Multiple HA Switches Per Printer UNIQUE Constraint — The migration that removes the UNIQUE constraint on
smart_plugs.printer_id(to allow multiple HA switches per printer) used an exact string match to detect the constraint in the SQLite schema. Databases created with older SQLAlchemy versions expressed the constraint differently (e.g. quoted column names, table-levelUNIQUE(printer_id), or separate indexes), so the migration silently skipped them. Users hitIntegrityError: UNIQUE constraint failedwhen assigning a second HA switch to a printer. Now uses regex pattern matching and also checks for standalone UNIQUE indexes. - HMS Notifications for Unknown/Phantom Error Codes — Printers send many undocumented or phantom HMS error codes that don't correspond to real errors (e.g. calibration status codes after firmware updates). These triggered email/push notifications even though the printer card correctly filtered them out. Flipped the notification logic from "notify all, suppress specific codes" to "only notify for errors with known descriptions", matching the frontend behavior. Also fixed the log message reporting incorrect notification counts.
- Ethernet Badge Shown on WiFi Printers / MQTT Disconnecting (#585) — Three bugs in the ethernet badge feature: (1)
home_flagbit 18 is set on all printers regardless of connection type, so every ethernet-capable model showed the ethernet badge even when connected via WiFi. Replaced bit 18 detection with wifi_signal-based heuristic: printers on ethernet with WiFi disabled report a hardcoded-90 dBmsentinel, while real WiFi signals vary. (2) The lazy import usedfrom app.utils.printer_modelswhich crashes withModuleNotFoundErrorin paho-mqtt's background thread (correct path isbackend.app.utils.printer_models). This killed the MQTT thread entirely, causing all printers to go stale after 60s and repeatedly disconnect/reconnect. (3) WiFi-only models (A1, P1P, etc.) that don't have an ethernet port are excluded via model-based gating. Reported by @cadtoolbox. - Inventory Usage Tracker Missing External Spool Mapping (#677) — When all higher-priority slot-to-tray mapping methods failed (MQTT mapping, print command mapping, queue mapping, color matching), the internal inventory usage tracker fell back to
slot_id - 1which can never reach external spool IDs (254/255) or AMS-HT IDs (128+). Added position-based resolution using sorted available tray IDs from the printer's AMS state, matching the fix applied to Spoolman tracking in #686. Contributed by @shrunbr. - Spool Assignment Applies Wrong Filament Profile (#681) — Assigning a spool with a specific filament variant (e.g. "Generic PLA Silk") to an AMS slot applied the base profile instead (e.g. "Generic PLA"). The Bambu Cloud API returns only the base
filament_idfor versioned setting IDs (GFSL99→GFL99), ignoring variant suffixes (GFSL99_01). Added a cross-check that compares the resolved filament name against the spool's stored preset name and corrects the filament ID via reverse lookup when they don't match (e.g.GFL99→GFL96for "Generic PLA Silk"). Also fixed the UI showing a stale preset name (e.g. "Bambu PLA Matte" instead of "Bambu PLA Silk") after assignment — the slot preset mapping was only saved when assigning via SpoolBuddy, not via the PrintersPage hover card. The backend now saves the slot preset mapping using the spool's authoritativeslicer_filament_nameafter every successful MQTT configuration, regardless of which UI path triggered the assignment. Reported by @peter-k-de, @RosdasHH. - Debug Logging Endpoint 500 Error — The
GET /api/v1/support/debug-loggingendpoint returned a 500 Internal Server Error when the database contained a timezone-aware timestamp written by a previous version. The duration calculation subtracted a timezone-aware datetime from a naivedatetime.now(), raisingTypeError. Now strips timezone info when reading the stored timestamp. - Bed Cooled Notification Never Fires (#497) — The bed cooldown monitor always timed out after 30 minutes without sending a notification. After print completion, P1S (and likely other models) sends partial MQTT status updates that don't include
bed_temper, so the cached bed temperature stayed frozen at the end-of-print value and never dropped below the threshold. The monitor now sends periodicpushallcommands to the printer to force fresh temperature data. Also added debug logging to the polling loop for future diagnostics. - Notification Provider Missing Event Toggles on Create (#497) — When creating a new notification provider, the
on_bed_cooledtoggle and all 7 queue event toggles (on_queue_job_added,on_queue_job_assigned,on_queue_job_started,on_queue_job_waiting,on_queue_job_skipped,on_queue_job_failed,on_queue_completed) were silently discarded. The create endpoint manually listed each field but omitted these 8 toggles, so they always defaulted tofalseregardless of user selection. Editing an existing provider worked correctly. - Clear Plate Prompt Shown for Staged Queue Items — The "Clear Plate & Start Next" button on the printer card appeared when all pending queue items were staged (
manual_start/Queue Only), even though the scheduler won't auto-start them. The clear plate prompt now only appears when there are auto-dispatchable items that the scheduler will actually start after the plate is cleared. - Ethernet Badge Shown on WiFi-Only Printers (#585) — The printer card network badge always showed "Ethernet" even on printers without an ethernet port. WiFi-only models (A1, P1P, etc.) are now excluded via model-based gating. Reported by @cadtoolbox.
- GitHub Backup Required Cloud Login (#655) — The GitHub backup settings card was completely blocked behind Bambu Cloud authentication, showing "Bambu Cloud login required" even though the backup feature works without it (K-profiles and app settings don't need cloud). Removed the cloud auth gate so GitHub backup can be configured and used without Bambu Cloud. The "Cloud Profiles" checkbox is disabled with a hint when not logged in. Reported by @TravisWilder.
- GitHub Backup Log Timestamps Off by 1 Hour — Backup log timestamps in the history table were displayed in UTC instead of the user's local timezone. The local
formatDateTimefunction didn't useparseUTCDate, so timezone-less timestamps from SQLite were interpreted as local time. Now uses the sharedparseUTCDateutility for correct UTC-to-local conversion. - H2D AMS Units Shown on Wrong Nozzle (#659) — On the H2D dual-nozzle printer, AMS units were displayed on the wrong nozzle (e.g. both AMS-HT and AMS2 Pro shown on the left nozzle instead of their correct assignments). Three interrelated bugs in the AMS
infofield parsing: (1) the field was parsed as decimal instead of hexadecimal (BambuStudio usesstd::stoull(str, nullptr, 16)), (2) the extruder ID was extracted as a single bit instead of a 4-bit field, and (3) partial MQTT updates overwrote the full extruder map instead of merging. Now correctly hex-parses theinfofield, extracts the 4-bit extruder ID from bits 8-11, skips uninitialized AMS units (0xE), and merges partial updates into the existing map. Reported by @cadtoolbox. - SD Card Error After FTP Upload (#645) — After printing one file, subsequent prints could fail with
0500-C010 "MicroSD Card read/write exception"until Bambuddy was restarted. The FTP upload usedtransfercmd()for A1 compatibility but skipped reading the server's 226 "Transfer complete" response, leaving the SD card file write unconfirmed. The print command was sent via MQTT before the printer's FTP server had finished flushing the file to disk. Now waits for the 226 confirmation after each upload (with a 60-second timeout for slower models like H2D). Reported by @lanfi89, confirmed by @Bademeister89. - P2S Shows Carbon Rod Maintenance Tasks (#640) — The P2S was incorrectly classified as a carbon rod printer, showing "Lubricate Carbon Rods" and "Clean Carbon Rods" maintenance tasks. The P2S uses hardened steel linear shafts, not carbon fiber rods. Added a new
steel_rodmotion system category and "Lubricate Steel Rods" / "Clean Steel Rods" maintenance tasks specific to the P2S. X1/P1 series continue to show carbon rod tasks; A1/H2 series continue to show linear rail tasks. Reported by @maziggy. - Dispatch Toast Stuck After Second Print — The print dispatch progress toast ("Starting prints…") stayed visible forever after the second print dispatch in a session. The dedup guard (
lastDispatchSummaryRef) that prevents duplicate completion toasts was never reset between batches, so every single-printer dispatch produced the same summary key ("first-complete:1:0"). The first print completed normally, but subsequent completions matched the stale ref and skipped creating the done toast — leaving the progress toast stuck in "Processing" state with no way to dismiss except a page reload. Now resets the dedup guard whenever the dispatch toast is dismissed (auto-dismiss timeout, cleanup events) and when a new batch starts. - Archive Card Buttons Overlapping at Narrow Widths (#641) — The "Reprint" and "Schedule" buttons at the bottom of archive cards overlapped when the browser window was narrower than the card grid expected (e.g. snapped to half-screen on a 2K monitor). The button text labels used a viewport-based
sm:breakpoint that didn't account for actual card width. Addedoverflow-hiddento the flex buttons andtruncateto the text spans so labels clip cleanly with ellipsis instead of bleeding into adjacent buttons. Reported by rsocko@outlook.com, confirmed by @dsmitty166. - Debug Logging Banner Timer Shows Negative Time — When enabling debug logging, the banner showed a negative duration (e.g. "-60m -59s") equal to the server's UTC offset. The
enabled_attimestamp was stored usingdatetime.now()(local time, no timezone indicator), but the frontend interpreted it as UTC. Now stores and compares all debug logging timestamps in UTC. - Non-Bambu Lab Spools Can't Link/Unlink to Spoolman (#653) — The "Link to Spoolman" button was not shown for non-Bambu Lab spools (which lack RFID tag UIDs). Now generates a fallback tag from the printer ID, AMS ID, and tray ID for spools without RFID identifiers. Also added an "Unlink from Spoolman" button for non-Bambu spools that are already linked. Contributed by @shrunbr.
- Spoolman Location Not Updated on Link/Unlink (#669) — Linking a spool to Spoolman did not set the spool's location field. Now sets the Spoolman location to the printer name, AMS name, and slot number (e.g. "P2S-1 - AMS-A 3") when linking, and clears it when unlinking. Contributed by @shrunbr.
- Print Dispatch Toast Disappears Instantly on Fast Uploads (#615) — When sending a print job, the notification popup disappeared instantly for small files or closed immediately when the progress bar reached 100% for larger files, giving no confirmation that the job was submitted. The dispatch toast now stays visible for 3 seconds after completion, showing a success message (e.g. "1 print started successfully") before auto-dismissing. For very fast uploads where the progress toast was never shown, a fresh confirmation toast is created instead. Reported by @aneopsy.
- Print Modal Shows Busy Printers as Selectable (#622) — When printing a file from the file manager, the print modal listed all printers including busy ones. Selecting a busy printer resulted in a failed send notification. The printer selector now fetches each printer's live status and shows a state badge (Idle, Printing, Paused, Preparing, Finished, Failed, Offline). In reprint mode, busy printers are grayed out and not selectable. "Select all" also skips busy printers. In queue mode, busy printers remain selectable since the job will wait. Reported by contact@aito3d.fr.
- PWA Install Not Available in Chrome (#629) — Chrome did not show the PWA install prompt because the manifest icons had incorrect dimensions (e.g. 190px wide declared as 192px) and the manifest was missing the
screenshotsentries required for Chrome's richer install UI. Resized all three icons (android-chrome-192x192.png,android-chrome-512x512.png,apple-touch-icon.png) to their declared sizes, split the discouraged"any maskable"purpose into a dedicated"maskable"entry, and added mobile and desktop screenshots to the manifest. Reported by @SebSeifert. - Project Statistics Count Archived Files as Printed (#630) — Files added to a project from the archive were counted in project statistics (completed prints, parts progress) as if they had already been printed. Only files with
status="completed"(actually printed via a printer) now count toward completion stats. Files withstatus="archived"(stored but not yet printed) are no longer included. Reported by @SebSeifert. - Python 3.10 Compatibility — Bambuddy failed to start on Python 3.10 with
ImportError: cannot import name 'StrEnum' from 'enum'becauseenum.StrEnumwas added in Python 3.11. Added a compatibility shim that falls back to(str, Enum)on Python < 3.11, matching the documented requirement of Python 3.10+. - Bug Report Bubble Overlapping Toasts — Moved toast notifications and upload progress up so they stack above the bug report bubble instead of overlapping on top of each other.
- Virtual Printer: Bind-TLS Proxy Handshake Failure on OpenSSL 3.x — The TLS proxy connecting to the printer's bind port (3002) failed with
SSLV3_ALERT_HANDSHAKE_FAILUREon systems with OpenSSL 3.x (e.g. Python 3.12+) because the default cipher set excludes plain RSA key exchange, which is the only mode Bambu printers support. AddedAES256-GCM-SHA384andAES128-GCM-SHA256to the client SSL context's cipher list. - Windows: Server Shuts Down After 60 Seconds (#605) — On Windows, terminating orphaned ffmpeg camera processes broadcast
CTRL_C_EVENTto the entire process group, causing uvicorn to interpret it as a user-initiated shutdown. ffmpeg is now spawned in its own process group (CREATE_NEW_PROCESS_GROUP) so cleanup no longer affects the server. Reported by @Reactantvr. - Multi-Printer Filament Mapping Shows Wrong Nozzle Filaments on Dual-Nozzle Printers (#624) — When selecting multiple printers for a print job on dual-nozzle printers (H2D), the per-printer filament mapping override dropdown showed filaments from both nozzles instead of only the correct nozzle for each slot. The single-printer filament mapping (FilamentMapping.tsx) was fixed in v0.2.1 to filter by
nozzle_id, but the multi-printer path (InlineMappingEditor in PrinterSelector.tsx) was missed. Both the auto-match logic and the dropdown options now filter bynozzle_id, matching the single-printer behavior. Reported by @cadtoolbox. - Filament Mapping Dropdowns Missing Subtypes (#624) — All filament mapping dropdowns (single-printer, multi-printer, and "Print to Any" model-based assignment) showed only the base material type (e.g., "PLA") without the subtype (e.g., "PLA Basic", "PLA Matte"). This made it impossible to distinguish between different filament variants of the same color. Now shows
tray_sub_brands(e.g., "PLA Basic", "PLA Matte", "PETG HF") in all filament dropdowns, falling back to the base type when no subtype is set. The backend's available-filaments endpoint also includestray_sub_brandsin the dedup key, so "PLA Basic Black" and "PLA Matte Black" appear as separate entries instead of collapsing into duplicate "PLA (Black)" rows. Reported by @cadtoolbox. - Archive Card Shows "Source" Badge for Sliced .3mf Files — Archive cards created from prints showed a "SOURCE" badge instead of "GCODE" when the filename was a plain
.3mf(without.gcodein the name). TheisSlicedFile()check only matched.gcodeor.gcode.3mfextensions, but.3mffiles can be either sliced (contains gcode) or raw source models. Now checks the archive'stotal_layersandprint_time_secondsmetadata — if either is present, the file is sliced. Also passes the original human-readable filename when creating archives from the file manager print flow (previously stored the UUID library filename). - AMS Slot Shows Wrong Material for "Support for" Profiles — Configuring an AMS slot with a filament profile like "PLA Support for PETG PETG Basic @Bambu Lab H2D 0.4 nozzle" set the slot material to PLA instead of PETG. The name parser iterated material types in order and returned the first match ("PLA"), ignoring that "PLA Support for PETG" means the filament type is PETG. Both the frontend
parsePresetName()and backend_parse_material_from_name()now detect the "X Support for Y" naming pattern and extract the material after "Support for". The frontend also prefers the corrected parsed material over the storedfilament_type(which may have been saved with the old parser during import). - Firmware Check Shows Wrong Version for H2D Pro (#584) — H2D Pro printers showed firmware as out of date because the firmware check matched against the H2D firmware track instead of the H2D Pro track. The firmware check's model-to-API-key mapping only had display names (e.g., "H2D", "H2D Pro") but not SSDP device codes (e.g., "O1E", "O2D"). Added all known SSDP model codes to the firmware check mapping so raw device codes resolve to the correct firmware track.
- Spurious Error Notifications During Normal Printing (0300_0002) — Some firmware versions send non-zero
print_errorvalues in MQTT during normal printing (e.g.,0x03000002→ short code0300_0002). Theprint_errorparser treated any non-zero value as a real error, appending it tohms_errorsand triggering notifications — even though the printer was printing fine. All known real HMS error codes have their low 16 bits >=0x4000(0x4xxx= fatal,0x8xxx= warning/pause,0xCxxx= prompt). Values below0x4000are status/phase indicators, not faults. Now skips values where the error portion is below0x4000in both theprint_errorandhmsarray parsers. - Spool Auto-Assign Fails With Greenlet Error (#612) — RFID spool auto-assignment logged
WARNING greenlet_spawn has not been called; can't call await_only() hereand silently failed. TheSpool.assignmentsrelationship was never eagerly loaded: whenauto_assign_spool()created a newSpoolAssignmentand calleddb.add(), SQLAlchemy resolved the FK back-populates synchronously (outside the async greenlet), triggering a lazy load on the uninitializedspool.assignmentscollection. The previous fix only coveredspool.k_profiles. Now also initializesspool.assignments = []on newly created spools increate_spool_from_tray(), and addsselectinload(Spool.assignments)to both queries inget_spool_by_tag()for existing spools. Addedexc_info=Trueto the error handlers for full tracebacks in future logs. - SpoolBuddy Link Tag Missing tag_type — Linking an NFC tag to a spool via the SpoolBuddy dashboard's "Link to Spool" action only set
tag_uidbut lefttag_typeanddata_originempty, because it called the genericupdateSpoolAPI instead of the dedicatedlinkTagToSpoolendpoint. The printer card'sLinkSpoolModalalready usedlinkTagToSpoolcorrectly. Now useslinkTagToSpoolwithtag_type: 'generic'anddata_origin: 'nfc_link', which also handles conflict checks and archived tag recycling. - SpoolBuddy AMS Page Missing Fill Levels for Non-BL Spools — AMS slots with non-Bambu Lab spools assigned to inventory didn't show fill level bars on the SpoolBuddy AMS page, even though the main printer card displayed them correctly. The SpoolBuddy AMS page only used the MQTT
remainfield (which is -1/unknown for non-BL spools), while the printer card had a fallback chain: Spoolman → inventory → AMS remain. Now fetches inventory spool assignments and computes fill levels from(label_weight - weight_used) / label_weight, falling back to AMS remain when no inventory assignment exists. - SpoolBuddy AMS Page Ext-R Slot Falsely Shown as Active When Idle — On dual-nozzle printers (H2D), the Ext-R slot was incorrectly highlighted as active when the printer was idle. The ext-R tray has
id=255, and the idle sentineltray_now=255matched it viatrayNow === extTrayId. The main printer card avoided this by clearingeffectiveTrayNowtoundefinedwhentray_now=255. Now guards againsttray_now=255before any ext slot active check. - Printer Card Loses Info When Print Is Paused (#562) — When a print was paused (via G-code pause command or user action), the printer card showed the print as finished — the progress bar, print name, ETA, layer count, and cover image all disappeared, replaced by the idle "Ready to Print" placeholder. The display conditions only checked for
state === 'RUNNING'but not'PAUSE', even though other parts of the same page (Skip Objects button, Stop/Resume controls) already handled both states correctly. Now shows print progress info for bothRUNNINGandPAUSEstates, and the status label correctly reads "Paused" instead of the hardcoded "Printing" fallback. - SpoolBuddy "Assign to AMS" Slot Shows Empty Fields in Slicer — After assigning a spool to an AMS slot via SpoolBuddy's "Assign to AMS" button, the slicer's slot overview showed the correct filament, but opening the slot detail card showed all fields empty/unselected. Two bugs: (1) the
assign_spoolbackend called the cloud API with the rawslicer_filamentvalue including its version suffix (e.g.,PFUS9ac902733670a9_07), which returned a 404; the silent fallback sent thesetting_idastray_info_idxinstead of the realfilament_id(e.g.,PFUS9ac902733670a9instead ofP4d64437), and the slicer couldn't resolve the preset; (2) noSlotPresetMappingwas saved, so Bambuddy's own ConfigureAmsSlotModal couldn't identify the active preset when reopened. Now strips version suffixes before the cloud lookup, resolves the realfilament_idvia the cloud API (with local preset and generic ID fallbacks), includes the brand name intray_sub_brands, and saves the slot preset mapping from the frontend after assignment. - Virtual Printer Bind Server Fails With TLS-Enabled Slicers (#559) — BambuStudio uses TLS on port 3002 for certain printer models (e.g. A1 Mini / N1), but the bind server only spoke plain TCP on both ports 3000 and 3002. The slicer's TLS ClientHello was rejected as an "invalid frame", preventing discovery and connection entirely. Port 3002 now uses TLS (using the VP's existing certificate), while port 3000 remains plain TCP for backwards compatibility. The proxy-mode bind proxy was also updated to use TLS termination on port 3002.
- Queue Returns 500 When Cancelled Print Exists (#558) — When a print was cancelled mid-print, the MQTT completion handler stored status
"aborted"on the queue item, but the response schema only accepts"pending","printing","completed","failed","skipped", or"cancelled". Listing all queue items hit a Pydantic validation error on the invalid status, returning a 500 error. Filtering by a specific status (e.g. "pending") excluded the bad row and worked fine. Now normalises"aborted"to"cancelled"before storing. A startup fixup also converts any existing"aborted"rows. - Tests Send Real Maintenance Notifications — Tests that call
on_print_complete(status="completed")created backgroundasynciotasks (maintenance check, smart plug, notifications) that outlived the test's mock context. When the event loop processed these orphaned tasks,async_sessionwas no longer patched and they queried the real production database — finding real printers with maintenance due and real notification providers, then sending real notifications. Tests now cancel spawned background tasks before the mock context exits. - Virtual Printer Config Changes Ignored Until Toggle Off/On — Changing a virtual printer's mode (e.g. proxy → archive), model, access code, bind IP, remote interface IP, or target printer via the UI updated the database but the running VP instance was never restarted.
sync_from_db()skipped any VP whose ID was already in the running instances dict without checking if config had changed. Now compares critical fields between the running instance and DB record and restarts the VP when a difference is detected. - Sidebar Navigation Ignores User Permissions — All sidebar navigation items (Archives, Queue, Stats, Profiles, Maintenance, Projects, Inventory, Files) were visible to every user regardless of their role's permissions. Only the Settings item was permission-gated. Now each nav item is hidden when the user lacks the corresponding read permission (e.g.,
archives:read,queue:read,library:read). The Printers item remains always visible as the home page. Also added the missinginventory:read|create|update|deletepermissions to the frontend Permission type (they existed in the backend but were absent from the frontend type definition). - Camera Button Clickable Without Permission & ffmpeg Process Leak (#550) — Two camera issues in multi-user environments (e.g., classrooms with multiple printers). First, the camera button on the printer card was clickable even when the user's role lacked
camera:viewpermission. Now disabled with a permission tooltip, matching the existing pattern forprinters:controlon the chamber light button. Second, ffmpeg processes (~240MB each) were never cleaned up after closing a camera stream. Thestop_camera_streamendpoint calledterminate()but neverwait()ed orkill()ed, and HTTP disconnect detection in the streaming response only checked between frames — if the generator was blocked reading from ffmpeg stdout, disconnect was never detected (due to TCP send buffer masking the closed connection). Three fixes: (1) the stop endpoint now usesterminate()→wait(2s)→kill()→wait(); (2) each stream gets a background disconnect monitor task that pollsrequest.is_disconnected()every 2 seconds independently of the frame loop, directly killing the ffmpeg process on disconnect; (3) a periodic cleanup (every 60s) scans/procfor any ffmpeg process with a Bambu RTSP URL (rtsps://bblp:) that isn't in an active stream andSIGKILLs it — catching orphans that survive app restarts or generator abandonment. - Windows Install Fails With "Syntax of the Command Is Incorrect" (#544) — The
start_bambuddy.batPython hash verification used a multi-linefor /f "usebackq"with a backtick-delimited command split across lines. Windows CMD cannot parse line breaks inside backtick-delimitedfor /fcommands, causing "The syntax of the command is incorrect" immediately after downloading Python. The entire block was also redundant — it downloaded a separate checksum file from python.org and re-verified the hash, butverify_sha256had already checked the archive against the pinned hash on the previous line. Removed the duplicate verification block. Also had a secondary bug: always downloaded theamd64checksum even onarm64systems. - Queue Badge Shows on Incompatible Printers (#486) — The purple queue counter badge in the printer card header showed on all printers of the same model when a job was scheduled for "any [model]", even if the printer didn't have the matching filament color loaded. The
PrinterQueueWidget(which shows "Clear Plate & Start") already filtered by filament type and color, but the badge count used the raw unfiltered queue length. Now applies the same filament compatibility filter to the badge count. - SpoolBuddy Daemon Can't Find Hardware Drivers — The daemon's
nfc_reader.pyandscale_reader.pyimportread_tagandscale_diagas bare modules, but these files live inspoolbuddy/scripts/which isn't on Python's module search path. The systemd service setsWorkingDirectorytospoolbuddy/and runspython -m daemon.main, so only thespoolbuddy/anddaemon/directories are onsys.path. Addedscripts/tosys.pathat daemon startup, resolved relative to the module file so it works regardless of install path. Also moved theread_tagimport insideNFCReader.__init__'s try/except block — it was previously outside, so a missing module crashed the entire daemon instead of gracefully skipping NFC polling. Demoted hardware-not-available log messages from ERROR to INFO since missing modules are expected when hardware isn't connected. - SpoolBuddy Scale Tare & Calibration Not Applied — The SpoolBuddy scale tare and calibrate buttons on the Settings page queued commands but never executed them. Five bugs in the chain: (1) the daemon received the
tarecommand via heartbeat but never calledscale.tare()— a comment said "need cross-task communication" but the ScaleReader was already available in the shared dict; (2) no API endpoint existed for the daemon to report the new tare offset back to the backend database, so tare results were lost; (3) when calibration values changed in heartbeat responses, the daemon updated its config object but never calledscale.update_calibration(), so the ScaleReader kept using its initial values forever; (4) the heartbeat response that delivered the tare command still contained pre-tare calibration values, which immediately overwrote the new tare offset back to zero; (5) theset-factorendpoint computedcalibration_factorusing the DBtare_offset, which could be stale or zero if the tare hadn't persisted yet — producing a wildly wrong factor (e.g., 5000g displayed with empty scale). Added aPOST /devices/{device_id}/calibration/set-tareendpoint andupdate_tare()API client method. The heartbeat loop now executesscale.tare()when the tare command is received, persists the result via the new endpoint, propagates calibration changes to the ScaleReader instance, and skips calibration sync on the heartbeat cycle that delivers a tare command. The calibration flow now captures the raw ADC at tare time and sends it alongside the loaded-weight ADC in step 2, so the factor is computed from the actual tare reference rather than the DB value — making calibration self-contained and independent of the tare persistence round-trip. The calibration weight input uses a compact touch-friendly numpad since the RPi kiosk has no physical keyboard. - A1 Mini Shows "Unknown" Status After MQTT Payload Decode Failure (#549) — Some printer firmware versions (observed on A1 Mini 01.07.02.00) occasionally send MQTT payloads containing non-UTF-8 bytes. The
_on_messagehandler calledmsg.payload.decode()(strict UTF-8), and the resultingUnicodeDecodeErrorwas not caught — onlyjson.JSONDecodeErrorwas handled. The entire message was silently dropped, causing printer status to show "unknown", temperatures to read 0°C, and AMS data to disappear. Now catchesUnicodeDecodeErrorand falls back todecode(errors="replace"), which substitutes invalid bytes with U+FFFD while keeping the JSON structure intact. Logs a warning for diagnostics. - H2C Dual Nozzle Variant (O1C2) Not Recognized (#489) — The H2C dual nozzle variant reports model code
O1C2via MQTT, but onlyO1Cwas in the recognized model maps. This caused the camera to use the wrong protocol (chamber image on port 6000 instead of RTSP on port 322) — the printer immediately closed the connection, producing a reconnect loop. Also affected model display names, chamber temperature support detection, linear rail classification, and virtual printer model mapping. AddedO1C2to all model ID maps across backend and frontend. - Support Package Leaks Full Subnet IPs and Misdetects Docker Network Mode — Three support package fixes. First, the network section included full subnet addresses (e.g.,
192.168.192.0/24); now masks the first two octets (x.x.192.0/24). Second,network_mode_hintusedlen(interfaces) > 2which always reported "bridge" on single-NIC hosts even withnetwork_mode: host, becauseget_network_interfaces()excludes Docker infrastructure interfaces. Now checks for the presence of Docker interfaces (docker0,br-*,veth*) viasocket.if_nameindex()— these are only visible when the container shares the host network namespace. Third,developer_modewas still null for most users because the MQTTfunfield was only parsed inside theprintkey; some firmware versions send it at the top level of the payload. Now also checks top-levelfun. Also added avirtual_printerssection with mode, model, enabled/running status, and pending file count for each configured virtual printer. - SpoolBuddy Scale Calibration Lost After Reboot — The SpoolBuddy daemon generated its device ID from the MAC address of whichever network interface
Path.iterdir()returned first, but filesystem iteration order is non-deterministic. On different boots, the daemon could picketh0(MAC ending3100) orwlan0(MAC ending3102), producing a differentdevice_ideach time. Since calibration values (tare_offset,calibration_factor) are stored per device ID in the backend database, a new ID meant registering as a brand-new uncalibrated device. Fixed by sorting network interfaces alphabetically before selection, ensuring the same interface (and thus the same device ID) is always chosen. - SpoolBuddy NFC Reader Fails to Detect Tags — The PN5180 NFC reader had two polling issues. First, each
activate_type_a()call that returnedNone(no tag) corrupted the PN5180 transceive state — subsequent calls silently failed even when a tag was physically present, making it impossible to detect tags placed after startup (only tags already on the reader during init were detected). Fixed by performing a full hardware reset (RST pin toggle + RF re-init, ~240ms) before every idle poll, giving a ~1.8 Hz effective poll rate. Second, after a successful SELECT the card stayed in ACTIVE state and ignored subsequent WUPA/REQA, causing false "tag removed" events after ~1 second. Fixed with a light RF off/on cycle (13ms) before each poll when a tag is present, resetting the card to IDLE for re-selection. Also added error-based auto-recovery (full hardware reset after 10 consecutive poll exceptions), periodic status logging every 60 seconds, and accurate heartbeat reporting of NFC/scale health.
Changed
- CI: Node.js 20 → 22 — Updated GitHub Actions workflows (
ci.yml,security.yml) from Node.js 20 to Node.js 22 LTS ahead of GitHub's Node 20 deprecation. - Daily Builds Falsely Trigger Update Notification — The version parser misclassified daily build tags (e.g.
0.2.2b4-daily.20260313) as full releases instead of betas, because the-daily.YYYYMMDDsuffix pushed the last dot-segment to a pure number (20260313), bypassing the prerelease detection. Users running the same beta version saw a spurious "update available" notification after each daily build. Now strips the daily suffix before parsing. - License changed from MIT to AGPL-3.0 — To prevent unauthorized redistribution of Bambuddy as a closed-source product. All existing contributions were made under MIT, which is forward-compatible with AGPL-3.0. Community contributions and usage are unaffected.
- License changed from MIT to AGPL-3.0 — To prevent unauthorized redistribution of Bambuddy as a closed-source product. All existing contributions were made under MIT, which is forward-compatible with AGPL-3.0. Community contributions and usage are unaffected.
Improved
- Shorter Inventory Location Labels — The location column in the Inventory table now shows compact labels like "H2D-1 B3" instead of "H2D-1 AMS-B Slot 3". External spool holders show "Ext" instead of "External". AMS-HT labels remain unchanged ("HT-A").
- Higher FTP Timeout Options for Large Files (#660) — Added 180s and 300s FTP timeout options in Settings. The previous maximum of 120s was insufficient for large 3MF files (e.g. 28 MB Hueforge models) which can't be downloaded from the printer's FTP server within 2 minutes, especially during active printing. Reported by @PasDoe.
- Separate Permission for AMS Spool Assignments (#635) — Added a new
inventory:view_assignmentspermission that controls whether spool-to-AMS-slot assignment data is visible on the Printers page. Previously, viewing spool assignments on printer cards requiredinventory:read, which also exposed the full Inventory page in the sidebar. Admins can now grantinventory:view_assignmentswithoutinventory:readso users can see what's loaded in the AMS without accessing the full spool inventory. All default groups (Administrators, Operators, Viewers) include the new permission automatically. Also fixed multi-word permission labels in the group editor (e.g. "Update_Own" → "Update Own"). Reported by @Minebuddy. - Prometheus Build Info Metric (#633) — Added a
bambuddy_build_infogauge metric to the Prometheus metrics endpoint, exposing the application version, Python version, platform, and architecture as labels. Follows the standard Prometheus_build_infoconvention for dashboards and version-change alerting. Contributed by @sw1nn. - i18n: Settings, Smart Plugs, Notifications, Backup/Restore — Replaced all hardcoded English strings with translation keys (
t()calls) across the Settings page, Smart Plug components (SmartPlugCard, AddSmartPlugModal, SwitchbarPopover), Notification components (NotificationProviderCard, AddNotificationModal, NotificationTemplateEditor, NotificationLogViewer), and Backup/Restore components (GitHubBackupSettings, RestoreModal). Added ~600 new translation keys to all 7 supported locales (en, de, ja, fr, it, pt-BR, zh-CN). Removed hardcoded label maps (PROVIDER_LABELS,EVENT_LABELS,CATEGORY_LABELS) in favor of dynamic translation key lookups with fallbacks. - Install Script: Branch Selection — The native install script (
install.sh) now supports a--branchoption and an interactive branch prompt (defaults tomain). Previously the script hardcodedorigin/main, so beta testers told to install from a beta branch would silently get the stable release instead. Fresh installs usegit clone --branch, existing installs checkout and reset to the selected branch. The install summary highlights non-main branches in yellow with a "(beta)" label. Invalid branch names are caught early with an error message listing available branches. - Print Queue Scheduler Diagnostics (#616) — Added diagnostic logging to the print queue scheduler to help diagnose why queued prints aren't starting. After each queue check, the scheduler now logs a skip summary (how many items were skipped due to manual_start, scheduled_time, etc.) and for each busy printer, logs the exact state preventing it from being considered idle (connected status, printer state, plate_cleared flag). Previously the scheduler only logged "found N pending items" with no visibility into why items were skipped.
- SpoolBuddy Settings Page Redesign — Redesigned the SpoolBuddy settings page with a tabbed layout (Device, Display, Scale, Updates). The Device tab shows an About section, NFC reader info (type, connection, status), device info (host, IP, uptime, online status), and device ID. The Display tab has a brightness slider (CSS software filter for HDMI displays) and screen blank timeout selector (Off, 1m, 2m, 5m, 10m, 30m) — the screen blanks after user inactivity (no touch) and wakes on tap. The Scale tab shows live weight with a step-indicator calibration wizard (tare → place known weight → calibrate). The Updates tab shows the daemon version and checks for updates against GitHub releases with optional beta inclusion. Display settings (brightness + blank timeout) are stored per-device in the backend and applied instantly in the frontend layout via outlet context.
- SpoolBuddy Language & Time Format Support — The SpoolBuddy kiosk now respects Bambuddy's configured UI language and time format. Added a
languagefield to backend app settings so the UI language is persisted server-side (previously only stored in browser localStorage, inaccessible to the kiosk's separate Chromium instance). The SpoolBuddy layout fetches settings on load and syncsi18n.changeLanguage(). The top bar clock usesformatTimeOnly()with the user's time format setting (system/12h/24h). Added full SpoolBuddy settings translations for all 6 supported languages (English, German, French, Japanese, Italian, Portuguese). - SpoolBuddy Kiosk Stability — Disabled Chromium's swipe-to-navigate gesture (
--overscroll-history-navigation=0) in the install script to prevent accidental back-navigation on the touchscreen. Added thevideogroup to the SpoolBuddy system user for DSI backlight access. - SpoolBuddy Touch-Friendly UI — Enlarged all interactive elements across the SpoolBuddy kiosk UI for comfortable finger use on the 1024×600 RPi touchscreen. Bottom nav icons and labels increased (20→24px icons, 10→12px labels, 48→56px bar height). Top bar printer selector and clock enlarged. Dashboard stats bar compacted, printers card removed (printer selection via top bar is sufficient), section headers and device status text bumped up. AMS page single-slot cards, spool visualizations, and fill bars enlarged. AMS unit cards get larger spool previews (56→64px), bigger material/slot text, and larger humidity/temperature indicators. Inventory spool cards, settings page headers, and calibration inputs all sized up to meet 44px minimum tap targets. The AMS slot configuration modal now renders in a two-column full-screen layout on the kiosk display (filament list on left, K-profile and color picker on right) instead of the standard centered dialog, eliminating scrolling.
- Ethernet Connection Indicator (#585) — Printers connected via ethernet now show a green "Ethernet" badge with a cable icon instead of the WiFi signal strength indicator. Detected via
home_flagbit 18 from the printer's MQTT data. The printer info modal also shows "Ethernet" instead of WiFi signal details. - SpoolBuddy AMS Page Single-Slot Card Layout — AMS-HT and external spool cards on the SpoolBuddy AMS page now use a responsive grid (2 cards per AMS card width) instead of auto-sized flex items, so they align with the regular AMS card columns above. Regular AMS cards no longer stretch vertically to fill available space on printers with fewer AMS units.
- SpoolBuddy Scale Value Stabilization — The SpoolBuddy daemon now suppresses redundant scale weight reports: only sends updates when the weight changes by ≥2g. Previously every 1-second report interval sent a reading regardless of change, and stability state flips (stable ↔ unstable) also triggered reports — when ADC noise kept the spread hovering around the 2g stability threshold, the flag toggled every cycle, forcing a report with a slightly different weight each time. Removed stability flipping as a report trigger (the stable flag is still included in each report for consumers). Also increased the NAU7802 moving average window from 5 to 20 samples (500ms → 2s) to smooth ADC noise. The frontend also applies a 3g display threshold as defense-in-depth.
- SpoolBuddy TopBar: Online Printer Selection — The printer selector in the SpoolBuddy top bar now only shows online printers and auto-selects the first online printer. If the currently selected printer goes offline, it automatically switches to the next available online printer. Also replaced the placeholder icon with the SpoolBuddy logo. Renamed the connection status label from "Online" to "Backend" for clarity.
- SpoolBuddy Assign to AMS Redesign — The "Assign to AMS" sub-modal (opened from the spool card) is now a full-screen overlay that reuses the
AmsUnitCardcomponent from the AMS page. Regular AMS units display in a 2-column grid with the same spool visualization, fill bars, and material labels. AMS-HT and external slots (Ext / Ext-L / Ext-R on dual-nozzle printers) appear in a compact horizontal row below. Clicking any slot auto-configures the filament via a singleassignSpoolAPI call — the backend handles both the DB assignment and MQTT configuration. The printer selector was removed from the modal since the top bar already provides printer selection. Dual-nozzle printers show L/R nozzle badges on each AMS unit. - Filament ID Conversion Utility — Extracted filament_id ↔ setting_id conversion logic into a shared utility (
backend/app/utils/filament_ids.py). Theassign_spoolendpoint now normalizesslicer_filament(which can be stored in either filament_id format like "GFL05" or setting_id format like "GFSL05_07") into the correcttray_info_idxandsetting_idfor the MQTT command. Previouslysetting_idwas always sent as empty string, which could cause BambuStudio to not resolve the filament preset for the AMS slot. - Updates Card Separates Firmware and Software Settings — The Updates card on the Settings page mixed printer firmware and Bambuddy software update toggles with no visual grouping. Now splits the card into two labeled sections ("Printer Firmware" and "Bambuddy Software") separated by a divider, making it clear which toggles control what.
- SpoolBuddy Test Coverage — Added integration tests for all 12 SpoolBuddy API endpoints (21 backend tests covering device registration/re-registration, heartbeat status and pending commands, NFC tag scan/match/removal, scale reading broadcast, spool weight calculation, and scale calibration including tare, set-factor, and zero-delta error handling) and component tests for the three main SpoolBuddy frontend components (20 frontend tests covering WeightDisplay weight formatting and status indicators, SpoolInfoCard spool info rendering and action callbacks, UnknownTagCard tag display, and TagDetectedModal open/close/escape behavior with known and unknown spool views).
- Cleanup Obsolete Settings — The startup migration now deletes orphaned settings keys from the database that are no longer used by the application (e.g.,
slicer_binary_pathfrom earlier slicer integration research). - Added HUF Currency (#579) — Added Hungarian Forint (HUF, Ft) to the supported currencies list for filament cost tracking.
- FTP Upload Progress & Speed — Reduced FTP upload chunk size from 1MB to 64KB for smoother progress reporting — at typical printer FTP speeds (~50-100KB/s) the progress bar now updates roughly every second instead of appearing stuck for 20+ seconds between jumps. Removed the post-upload
voidresp()wait for all printer models (previously only skipped for A1); H2D printers delay the FTP 226 acknowledgment by 30+ seconds after data transfer completes, causing a long hang at 100%. The data is already on the SD card once the transfer finishes. Also added transfer speed logging (KB/s) and PASV+TLS handshake timing to help diagnose slow connections. - Wider Print & Schedule Modals — Increased the Print and Schedule Print modal width from 512px to 672px to better accommodate long filament profile names (e.g., "PLA Support for PETG PETG Basic @Bambu Lab H2D 0.4 nozzle").
Security
- Stored XSS via Project Notes — Project notes were rendered with
dangerouslySetInnerHTMLwithout sanitization, allowing injected<script>or event handler payloads to execute in any viewer's browser and steal JWT tokens from localStorage. Now sanitized with DOMPurify before rendering. - Stored XSS via 3MF Description (Sanitizer Bypass) — The hand-rolled HTML sanitizer in the Project Page modal reconstructed
<a>tags by interpolating thehrefattribute without escaping embedded quotes. A crafted 3MF file with a single-quotedhrefcontaining a double-quote break-out could injectonmouseoverevent handlers through the sanitizer. Replaced the custom sanitizer with DOMPurify. - Unauthenticated Auth Toggle via Setup Endpoint — The
/api/v1/auth/setupendpoint could be called without authentication even when auth was already enabled, allowing any network client to disable authentication entirely. Now returns 403 when auth is already enabled; use the authenticated admin panel to modify auth settings. - PyJWT ≥2.12.0 — Bumped minimum version to address CVE-2026-32597.
- flatted ≥3.4.0 — Updated transitive ESLint dependency to address GHSA-25h7-pfq9-p65f (unbounded recursion DoS).
- Access Code Redacted from Support Logs — Printer access codes embedded in RTSP stream URLs were not redacted in support bundles and bug report logs. Extended the URL credential sanitizer to cover
rtsps://URLs and added access codes to the sensitive string collection for exact-match redaction.
[0.2.2b3] - 2026-03-12
New Features
- Home Assistant Notification Provider (#656) — Added Home Assistant as a notification provider. When HA is configured in Settings → Network → Home Assistant, selecting "Home Assistant" as a notification provider sends persistent notifications to the HA dashboard — no additional configuration needed. From there, HA automations can forward notifications to mobile apps, WhatsApp, or any other service. Requested by @TravisWilder.
- Virtual Printer Queue Auto-Dispatch Toggle (#587) — Added an "Auto-dispatch" toggle to virtual printers in Queue mode. When enabled (default), prints sent from the slicer are added to the queue and start automatically on the assigned printer — matching the current behavior. When disabled, prints are added to the queue with
manual_startset, so they wait for manual dispatch. This allows users who want to review and manually assign prints before they start. Requested by @Percy2Live. - Queue All Plates (#530) — Multi-plate 3MF files can now be queued in one action. When adding a multi-plate file to the queue, a "Queue All N Plates" toggle appears in the plate selector. When activated, every plate is added as a separate queue entry (one per plate × per selected printer), each individually editable from the queue page. The toggle is only available in add-to-queue mode (not reprint or edit). Requested by @Dendrowen.
- Malaysian Ringgit Currency (#634) — Added MYR (RM) to the list of supported currencies for filament cost tracking. Requested by @cynogen127.
- ETA Variable in Notifications (#638) — Added
{eta}template variable to print start, print progress, and queue job started notifications. Shows the estimated wall-clock completion time (e.g. "15:53" or "3:53 PM") based on the user's configured time format (12h/24h). Existing{estimated_time}still shows duration ("1h 23m"). Requested by @SebSeifert. - Bulk Delete Spool and Color Catalog Entries (#646) — Added checkbox selection and bulk delete to both the Spool Catalog and Color Catalog in Settings > Filament. Select individual entries with checkboxes, use the header checkbox to select/deselect all visible entries, then click "Delete Selected" to remove them in one operation. Previously, entries could only be deleted one at a time. Requested by @SebSeifert.
- Force Color Match (#625) — Added a "Force Color Match" option for "Print to Any" queue scheduling. When enabled, the scheduler requires a strict color match when assigning prints to printers, preventing incorrect filament assignments when multiple candidates are close in color. Prints wait in the queue until a printer with the exact matching filament is available. Contributed by @cadtoolbox.
- Israeli New Shekel Currency — Added ILS (₪) to the list of supported currencies for filament cost tracking.
Changes
- License changed from MIT to AGPL-3.0 — To prevent unauthorized redistribution of Bambuddy as a closed-source product. All existing contributions were made under MIT, which is forward-compatible with AGPL-3.0. Community contributions and usage are unaffected.
Improved
- Shorter Inventory Location Labels — The location column in the Inventory table now shows compact labels like "H2D-1 B3" instead of "H2D-1 AMS-B Slot 3". External spool holders show "Ext" instead of "External". AMS-HT labels remain unchanged ("HT-A").
- Higher FTP Timeout Options for Large Files (#660) — Added 180s and 300s FTP timeout options in Settings. The previous maximum of 120s was insufficient for large 3MF files (e.g. 28 MB Hueforge models) which can't be downloaded from the printer's FTP server within 2 minutes, especially during active printing. Reported by @PasDoe.
- Separate Permission for AMS Spool Assignments (#635) — Added a new
inventory:view_assignmentspermission that controls whether spool-to-AMS-slot assignment data is visible on the Printers page. Previously, viewing spool assignments on printer cards requiredinventory:read, which also exposed the full Inventory page in the sidebar. Admins can now grantinventory:view_assignmentswithoutinventory:readso users can see what's loaded in the AMS without accessing the full spool inventory. All default groups (Administrators, Operators, Viewers) include the new permission automatically. Also fixed multi-word permission labels in the group editor (e.g. "Update_Own" → "Update Own"). Reported by @Minebuddy. - Prometheus Build Info Metric (#633) — Added a
bambuddy_build_infogauge metric to the Prometheus metrics endpoint, exposing the application version, Python version, platform, and architecture as labels. Follows the standard Prometheus_build_infoconvention for dashboards and version-change alerting. Contributed by @sw1nn.
Fixed
- Beta Updates Shown When Disabled (#731) — Daily beta builds (e.g.
v0.2.3b1-daily.20260316) were offered as updates even with "Include beta versions" toggled off. The version parser only checked the last dot-separated segment for prerelease markers, but daily build tags put the beta indicator (b1) earlier with a numeric date suffix as the last segment. Now checks the entire version string. Reported by @Teolhyn. - Debug Logging Endpoint 500 Error — The
GET /api/v1/support/debug-loggingendpoint returned a 500 Internal Server Error when the database contained a timezone-aware timestamp written by a previous version. The duration calculation subtracted a timezone-aware datetime from a naivedatetime.now(), raisingTypeError. Now strips timezone info when reading the stored timestamp. - Bed Cooled Notification Never Fires (#497) — The bed cooldown monitor always timed out after 30 minutes without sending a notification. After print completion, P1S (and likely other models) sends partial MQTT status updates that don't include
bed_temper, so the cached bed temperature stayed frozen at the end-of-print value and never dropped below the threshold. The monitor now sends periodicpushallcommands to the printer to force fresh temperature data. Also added debug logging to the polling loop for future diagnostics. - Notification Provider Missing Event Toggles on Create (#497) — When creating a new notification provider, the
on_bed_cooledtoggle and all 7 queue event toggles (on_queue_job_added,on_queue_job_assigned,on_queue_job_started,on_queue_job_waiting,on_queue_job_skipped,on_queue_job_failed,on_queue_completed) were silently discarded. The create endpoint manually listed each field but omitted these 8 toggles, so they always defaulted tofalseregardless of user selection. Editing an existing provider worked correctly. - Clear Plate Prompt Shown for Staged Queue Items — The "Clear Plate & Start Next" button on the printer card appeared when all pending queue items were staged (
manual_start/Queue Only), even though the scheduler won't auto-start them. The clear plate prompt now only appears when there are auto-dispatchable items that the scheduler will actually start after the plate is cleared. - Ethernet Badge Shown on WiFi-Only Printers (#585) — The printer card network badge always showed "Ethernet" even on printers without an ethernet port. WiFi-only models (A1, P1P, etc.) are now excluded via model-based gating. Reported by @cadtoolbox.
- GitHub Backup Required Cloud Login (#655) — The GitHub backup settings card was completely blocked behind Bambu Cloud authentication, showing "Bambu Cloud login required" even though the backup feature works without it (K-profiles and app settings don't need cloud). Removed the cloud auth gate so GitHub backup can be configured and used without Bambu Cloud. The "Cloud Profiles" checkbox is disabled with a hint when not logged in. Reported by @TravisWilder.
- GitHub Backup Log Timestamps Off by 1 Hour — Backup log timestamps in the history table were displayed in UTC instead of the user's local timezone. The local
formatDateTimefunction didn't useparseUTCDate, so timezone-less timestamps from SQLite were interpreted as local time. Now uses the sharedparseUTCDateutility for correct UTC-to-local conversion. - H2D AMS Units Shown on Wrong Nozzle (#659) — On the H2D dual-nozzle printer, AMS units were displayed on the wrong nozzle (e.g. both AMS-HT and AMS2 Pro shown on the left nozzle instead of their correct assignments). Three interrelated bugs in the AMS
infofield parsing: (1) the field was parsed as decimal instead of hexadecimal (BambuStudio usesstd::stoull(str, nullptr, 16)), (2) the extruder ID was extracted as a single bit instead of a 4-bit field, and (3) partial MQTT updates overwrote the full extruder map instead of merging. Now correctly hex-parses theinfofield, extracts the 4-bit extruder ID from bits 8-11, skips uninitialized AMS units (0xE), and merges partial updates into the existing map. Reported by @cadtoolbox. - SD Card Error After FTP Upload (#645) — After printing one file, subsequent prints could fail with
0500-C010 "MicroSD Card read/write exception"until Bambuddy was restarted. The FTP upload usedtransfercmd()for A1 compatibility but skipped reading the server's 226 "Transfer complete" response, leaving the SD card file write unconfirmed. The print command was sent via MQTT before the printer's FTP server had finished flushing the file to disk. Now waits for the 226 confirmation after each upload (with a 60-second timeout for slower models like H2D). Reported by @lanfi89, confirmed by @Bademeister89. - P2S Shows Carbon Rod Maintenance Tasks (#640) — The P2S was incorrectly classified as a carbon rod printer, showing "Lubricate Carbon Rods" and "Clean Carbon Rods" maintenance tasks. The P2S uses hardened steel linear shafts, not carbon fiber rods. Added a new
steel_rodmotion system category and "Lubricate Steel Rods" / "Clean Steel Rods" maintenance tasks specific to the P2S. X1/P1 series continue to show carbon rod tasks; A1/H2 series continue to show linear rail tasks. Reported by @maziggy. - Dispatch Toast Stuck After Second Print — The print dispatch progress toast ("Starting prints…") stayed visible forever after the second print dispatch in a session. The dedup guard (
lastDispatchSummaryRef) that prevents duplicate completion toasts was never reset between batches, so every single-printer dispatch produced the same summary key ("first-complete:1:0"). The first print completed normally, but subsequent completions matched the stale ref and skipped creating the done toast — leaving the progress toast stuck in "Processing" state with no way to dismiss except a page reload. Now resets the dedup guard whenever the dispatch toast is dismissed (auto-dismiss timeout, cleanup events) and when a new batch starts. - Archive Card Buttons Overlapping at Narrow Widths (#641) — The "Reprint" and "Schedule" buttons at the bottom of archive cards overlapped when the browser window was narrower than the card grid expected (e.g. snapped to half-screen on a 2K monitor). The button text labels used a viewport-based
sm:breakpoint that didn't account for actual card width. Addedoverflow-hiddento the flex buttons andtruncateto the text spans so labels clip cleanly with ellipsis instead of bleeding into adjacent buttons. Reported by rsocko@outlook.com, confirmed by @dsmitty166. - Debug Logging Banner Timer Shows Negative Time — When enabling debug logging, the banner showed a negative duration (e.g. "-60m -59s") equal to the server's UTC offset. The
enabled_attimestamp was stored usingdatetime.now()(local time, no timezone indicator), but the frontend interpreted it as UTC. Now stores and compares all debug logging timestamps in UTC. - Non-Bambu Lab Spools Can't Link/Unlink to Spoolman (#653) — The "Link to Spoolman" button was not shown for non-Bambu Lab spools (which lack RFID tag UIDs). Now generates a fallback tag from the printer ID, AMS ID, and tray ID for spools without RFID identifiers. Also added an "Unlink from Spoolman" button for non-Bambu spools that are already linked. Contributed by @shrunbr.
- Spoolman Location Not Updated on Link/Unlink (#669) — Linking a spool to Spoolman did not set the spool's location field. Now sets the Spoolman location to the printer name, AMS name, and slot number (e.g. "P2S-1 - AMS-A 3") when linking, and clears it when unlinking. Contributed by @shrunbr.
[0.2.2b2] - 2026-03-06
New Features
- AMS Info Card & Custom Labels (#570) — Hovering an AMS label (e.g. "AMS-A") on the Printers page now shows a popover with serial number, firmware version, and an editable friendly name. Custom labels are stored by AMS serial number so they persist when the unit is moved to a different printer. Slot numbers are now displayed inside each filament color circle with auto-inverted contrast for readability. Labels also appear in the Inventory page's location column. Contributed by @cadtoolbox.
Changes
- License changed from MIT to AGPL-3.0 — To prevent unauthorized redistribution of Bambuddy as a closed-source product. All existing contributions were made under MIT, which is forward-compatible with AGPL-3.0. Community contributions and usage are unaffected.
Improved
- i18n: Settings, Smart Plugs, Notifications, Backup/Restore — Replaced all hardcoded English strings with translation keys (
t()calls) across the Settings page, Smart Plug components (SmartPlugCard, AddSmartPlugModal, SwitchbarPopover), Notification components (NotificationProviderCard, AddNotificationModal, NotificationTemplateEditor, NotificationLogViewer), and Backup/Restore components (GitHubBackupSettings, RestoreModal). Added ~600 new translation keys to all 7 supported locales (en, de, ja, fr, it, pt-BR, zh-CN). Removed hardcoded label maps (PROVIDER_LABELS,EVENT_LABELS,CATEGORY_LABELS) in favor of dynamic translation key lookups with fallbacks. - Install Script: Branch Selection — The native install script (
install.sh) now supports a--branchoption and an interactive branch prompt (defaults tomain). Previously the script hardcodedorigin/main, so beta testers told to install from a beta branch would silently get the stable release instead. Fresh installs usegit clone --branch, existing installs checkout and reset to the selected branch. The install summary highlights non-main branches in yellow with a "(beta)" label. Invalid branch names are caught early with an error message listing available branches. - Print Queue Scheduler Diagnostics (#616) — Added diagnostic logging to the print queue scheduler to help diagnose why queued prints aren't starting. After each queue check, the scheduler now logs a skip summary (how many items were skipped due to manual_start, scheduled_time, etc.) and for each busy printer, logs the exact state preventing it from being considered idle (connected status, printer state, plate_cleared flag). Previously the scheduler only logged "found N pending items" with no visibility into why items were skipped.
Fixed
- Print Dispatch Toast Disappears Instantly on Fast Uploads (#615) — When sending a print job, the notification popup disappeared instantly for small files or closed immediately when the progress bar reached 100% for larger files, giving no confirmation that the job was submitted. The dispatch toast now stays visible for 3 seconds after completion, showing a success message (e.g. "1 print started successfully") before auto-dismissing. For very fast uploads where the progress toast was never shown, a fresh confirmation toast is created instead. Reported by @aneopsy.
- Print Modal Shows Busy Printers as Selectable (#622) — When printing a file from the file manager, the print modal listed all printers including busy ones. Selecting a busy printer resulted in a failed send notification. The printer selector now fetches each printer's live status and shows a state badge (Idle, Printing, Paused, Preparing, Finished, Failed, Offline). In reprint mode, busy printers are grayed out and not selectable. "Select all" also skips busy printers. In queue mode, busy printers remain selectable since the job will wait. Reported by contact@aito3d.fr.
- PWA Install Not Available in Chrome (#629) — Chrome did not show the PWA install prompt because the manifest icons had incorrect dimensions (e.g. 190px wide declared as 192px) and the manifest was missing the
screenshotsentries required for Chrome's richer install UI. Resized all three icons (android-chrome-192x192.png,android-chrome-512x512.png,apple-touch-icon.png) to their declared sizes, split the discouraged"any maskable"purpose into a dedicated"maskable"entry, and added mobile and desktop screenshots to the manifest. Reported by @SebSeifert. - Project Statistics Count Archived Files as Printed (#630) — Files added to a project from the archive were counted in project statistics (completed prints, parts progress) as if they had already been printed. Only files with
status="completed"(actually printed via a printer) now count toward completion stats. Files withstatus="archived"(stored but not yet printed) are no longer included. Reported by @SebSeifert. - Python 3.10 Compatibility — Bambuddy failed to start on Python 3.10 with
ImportError: cannot import name 'StrEnum' from 'enum'becauseenum.StrEnumwas added in Python 3.11. Added a compatibility shim that falls back to(str, Enum)on Python < 3.11, matching the documented requirement of Python 3.10+. - Bug Report Bubble Overlapping Toasts — Moved toast notifications and upload progress up so they stack above the bug report bubble instead of overlapping on top of each other.
- Virtual Printer: Bind-TLS Proxy Handshake Failure on OpenSSL 3.x — The TLS proxy connecting to the printer's bind port (3002) failed with
SSLV3_ALERT_HANDSHAKE_FAILUREon systems with OpenSSL 3.x (e.g. Python 3.12+) because the default cipher set excludes plain RSA key exchange, which is the only mode Bambu printers support. AddedAES256-GCM-SHA384andAES128-GCM-SHA256to the client SSL context's cipher list. - Windows: Server Shuts Down After 60 Seconds (#605) — On Windows, terminating orphaned ffmpeg camera processes broadcast
CTRL_C_EVENTto the entire process group, causing uvicorn to interpret it as a user-initiated shutdown. ffmpeg is now spawned in its own process group (CREATE_NEW_PROCESS_GROUP) so cleanup no longer affects the server. Reported by @Reactantvr. - Multi-Printer Filament Mapping Shows Wrong Nozzle Filaments on Dual-Nozzle Printers (#624) — When selecting multiple printers for a print job on dual-nozzle printers (H2D), the per-printer filament mapping override dropdown showed filaments from both nozzles instead of only the correct nozzle for each slot. The single-printer filament mapping (FilamentMapping.tsx) was fixed in v0.2.1 to filter by
nozzle_id, but the multi-printer path (InlineMappingEditor in PrinterSelector.tsx) was missed. Both the auto-match logic and the dropdown options now filter bynozzle_id, matching the single-printer behavior. Reported by @cadtoolbox. - Filament Mapping Dropdowns Missing Subtypes (#624) — All filament mapping dropdowns (single-printer, multi-printer, and "Print to Any" model-based assignment) showed only the base material type (e.g., "PLA") without the subtype (e.g., "PLA Basic", "PLA Matte"). This made it impossible to distinguish between different filament variants of the same color. Now shows
tray_sub_brands(e.g., "PLA Basic", "PLA Matte", "PETG HF") in all filament dropdowns, falling back to the base type when no subtype is set. The backend's available-filaments endpoint also includestray_sub_brandsin the dedup key, so "PLA Basic Black" and "PLA Matte Black" appear as separate entries instead of collapsing into duplicate "PLA (Black)" rows. Reported by @cadtoolbox.
[0.2.2b1] - 2026-03-03
Improved
- SpoolBuddy Settings Page Redesign — Redesigned the SpoolBuddy settings page with a tabbed layout (Device, Display, Scale, Updates). The Device tab shows an About section, NFC reader info (type, connection, status), device info (host, IP, uptime, online status), and device ID. The Display tab has a brightness slider (CSS software filter for HDMI displays) and screen blank timeout selector (Off, 1m, 2m, 5m, 10m, 30m) — the screen blanks after user inactivity (no touch) and wakes on tap. The Scale tab shows live weight with a step-indicator calibration wizard (tare → place known weight → calibrate). The Updates tab shows the daemon version and checks for updates against GitHub releases with optional beta inclusion. Display settings (brightness + blank timeout) are stored per-device in the backend and applied instantly in the frontend layout via outlet context.
- SpoolBuddy Language & Time Format Support — The SpoolBuddy kiosk now respects Bambuddy's configured UI language and time format. Added a
languagefield to backend app settings so the UI language is persisted server-side (previously only stored in browser localStorage, inaccessible to the kiosk's separate Chromium instance). The SpoolBuddy layout fetches settings on load and syncsi18n.changeLanguage(). The top bar clock usesformatTimeOnly()with the user's time format setting (system/12h/24h). Added full SpoolBuddy settings translations for all 6 supported languages (English, German, French, Japanese, Italian, Portuguese). - SpoolBuddy Kiosk Stability — Disabled Chromium's swipe-to-navigate gesture (
--overscroll-history-navigation=0) in the install script to prevent accidental back-navigation on the touchscreen. Added thevideogroup to the SpoolBuddy system user for DSI backlight access. - SpoolBuddy Touch-Friendly UI — Enlarged all interactive elements across the SpoolBuddy kiosk UI for comfortable finger use on the 1024×600 RPi touchscreen. Bottom nav icons and labels increased (20→24px icons, 10→12px labels, 48→56px bar height). Top bar printer selector and clock enlarged. Dashboard stats bar compacted, printers card removed (printer selection via top bar is sufficient), section headers and device status text bumped up. AMS page single-slot cards, spool visualizations, and fill bars enlarged. AMS unit cards get larger spool previews (56→64px), bigger material/slot text, and larger humidity/temperature indicators. Inventory spool cards, settings page headers, and calibration inputs all sized up to meet 44px minimum tap targets. The AMS slot configuration modal now renders in a two-column full-screen layout on the kiosk display (filament list on left, K-profile and color picker on right) instead of the standard centered dialog, eliminating scrolling.
- Ethernet Connection Indicator (#585) — Printers connected via ethernet now show a green "Ethernet" badge with a cable icon instead of the WiFi signal strength indicator. Detected via
home_flagbit 18 from the printer's MQTT data. The printer info modal also shows "Ethernet" instead of WiFi signal details.
New Features
- In-App Bug Reporting — A floating bug report button in the bottom-right corner lets users submit bug reports directly from the Bambuddy UI. Reports include a description, optional screenshot (upload, paste, or drag & drop with automatic JPEG compression), optional contact email, and automatically collected diagnostic data. On submit, the system temporarily enables debug logging, sends push_all to all connected printers, waits 30 seconds to collect fresh logs, then submits everything to a secure relay on bambuddy.cool which creates a GitHub issue with sanitized logs uploaded as a separate file. All sensitive data (printer names, serial numbers, IPs, credentials, email addresses) is redacted from logs before submission. The expandable data privacy notice details exactly what is and isn't collected. Translated into all 7 supported languages.
- SpoolBuddy NFC Tag Writing (OpenTag3D) — SpoolBuddy can now write NFC tags for third-party filament spools using the OpenTag3D format on NTAG213/215/216 stickers. A new "Write" page (
/spoolbuddy/write-tag) in the kiosk UI provides three workflows: write a tag for an existing inventory spool (no tag linked yet), create a new spool and write in one flow, or replace a damaged tag (unlinks old, writes new). The left panel shows a searchable spool list or a compact creation form (material dropdown, color picker, brand, weight); the right panel shows real-time NFC status with tag detection, a spool summary, and the write button. The backend encodes spool data as a 133-byte OpenTag3D NDEF message (MIME typeapplication/opentag3d, fits NTAG213's 144-byte capacity) containing material, color, brand, weight, temperature, and RGBA color data. The write command flows through the existing heartbeat polling mechanism — the frontend queues a write, the daemon picks it up on the next heartbeat, writes page-by-page with read-back verification via the PN5180's NTAG WRITE (0xA2) command, and reports success/failure via WebSocket. On success the tag UID is automatically linked to the spool withdata_origin=opentag3d. Written tags are readable by any OpenTag3D-compatible reader including SpoolBuddy itself. Translations added for all 6 languages. - SpoolBuddy On-Screen Keyboard — Added a virtual QWERTY keyboard for the SpoolBuddy kiosk UI (and login page) since the Raspberry Pi has no physical keyboard and system-level virtual keyboards (squeekboard, wvkbd) don't auto-show/hide in the labwc/Chromium kiosk environment. Uses
react-simple-keyboardwith a dark theme matching the bambu-dark/bambu-green palette. Auto-shows when any text/password/email input is focused, supports shift, caps lock, backspace, and email-friendly keys (@, .). Inputs withdata-vkb="false"are excluded (e.g. SpoolBuddySettingsPage's own numpad). A two-phase close prevents ghost-click passthrough to elements underneath the keyboard. - SpoolBuddy Inline Spool Cards — Placing an NFC-tagged spool on the SpoolBuddy reader now shows spool info directly in the dashboard's right panel instead of a separate modal overlay. Known spools display a SpoolIcon with color/brand/material, a large remaining-weight readout with fill bar, and a weight comparison grid, with action buttons for "Assign to AMS", "Sync Weight", and "Close". Unknown tags show the tag UID, scale weight, and offer "Add to Inventory" or "Link to Spool" actions. The card stays visible if the tag is removed (for continued interaction) and won't re-appear for the same tag after dismissal — but re-placing a tag after removal shows it again. The idle spool animation displays when no tag is detected.
- SpoolBuddy AMS Page: External Slots & Slot Configuration — The SpoolBuddy AMS page (
/spoolbuddy/ams) now displays external spool slots (single nozzle: "Ext", dual nozzle: "Ext-L"/"Ext-R") and AMS-HT units in a compact horizontal row below the regular AMS grid, fitting within the 1024×600 kiosk display without scrolling. Clicking any AMS, AMS-HT, or external slot opens theConfigureAmsSlotModalto configure filament type and color — the same modal used on the main Printers page. Dual-nozzle printers show L/R nozzle badges on each AMS unit. Temperature and humidity are displayed with threshold-colored SVG icons (green/gold/red) matching the Bambu Lab style on the main printer cards, using the configured AMS humidity and temperature thresholds from settings. - SpoolBuddy Dashboard Redesign — Redesigned the SpoolBuddy dashboard with a two-column layout: left column shows device connection status (scale and NFC with state-colored icons — green when device is online, gray when offline) and printer status badges below (compact pills with green/gray dots for online/offline, wrapping to fit without scrolling); right column shows the current spool card. Cards use a dashed border style for a cleaner look. The large weight display card was removed in favor of the inline scale reading in the device card. Unknown NFC tags now offer a quick-add modal that creates a basic PLA spool entry linked to the tag — with a hint recommending users add spools via the main Bambuddy UI first for full details. The separate SpoolBuddy inventory page was removed since inventory management belongs in the main Bambuddy frontend; the bottom nav now has three tabs (Dashboard, AMS, Settings).
- SpoolBuddy Kiosk Auth Bypass via API Key — When Bambuddy auth is enabled, the SpoolBuddy kiosk (Chromium on RPi) was redirected to the login page because the
ProtectedRouterequires a user object fromGET /auth/me, which only accepted JWT tokens. The/auth/meendpoint now also accepts API keys (viaAuthorization: Bearer bb_xxxorX-API-Keyheader) and returns a synthetic admin user with all permissions. The frontend'sAuthContextreads an optional?token=URL parameter on first load, stores it in localStorage, and strips it from the URL to prevent leakage via browser history or referrer. The install script now includes the API key in the kiosk URL (/spoolbuddy?token=${API_KEY}), so the device authenticates automatically on boot without manual login. - Daily Beta Builds — Added a release script (
docker-publish-daily-beta.sh) that reads the currentAPP_VERSIONfrom config, builds a multi-arch Docker image, pushes to both GHCR and Docker Hub, and creates/updates a GitHub prerelease with changelog notes. Daily builds overwrite the same beta version tag (e.g.,0.2.2b1) — users pull the latest by re-pulling the tag or using Watchtower. Beta images are never tagged aslatest. - Inventory Scale Weight Check Column — Added a "Weight Check" column (hidden by default) to the inventory table that compares each spool's last scale measurement against its calculated gross weight (net remaining + core weight). Spools within a ±50g tolerance show a green checkmark; mismatched spools show a yellow warning with the difference and a sync button that trusts the scale reading and resets weight tracking. The backend stores
last_scale_weightandlast_weighed_aton each spool whenever weight is synced via SpoolBuddy, and the column tooltip shows scale weight, calculated weight, and difference. Edge case: when scale weight is below core weight (empty spool or not on scale), the comparison treats it as a match since sync can't correct this.
Fixed
- Archive Card Shows "Source" Badge for Sliced .3mf Files — Archive cards created from prints showed a "SOURCE" badge instead of "GCODE" when the filename was a plain
.3mf(without.gcodein the name). TheisSlicedFile()check only matched.gcodeor.gcode.3mfextensions, but.3mffiles can be either sliced (contains gcode) or raw source models. Now checks the archive'stotal_layersandprint_time_secondsmetadata — if either is present, the file is sliced. Also passes the original human-readable filename when creating archives from the file manager print flow (previously stored the UUID library filename). - AMS Slot Shows Wrong Material for "Support for" Profiles — Configuring an AMS slot with a filament profile like "PLA Support for PETG PETG Basic @Bambu Lab H2D 0.4 nozzle" set the slot material to PLA instead of PETG. The name parser iterated material types in order and returned the first match ("PLA"), ignoring that "PLA Support for PETG" means the filament type is PETG. Both the frontend
parsePresetName()and backend_parse_material_from_name()now detect the "X Support for Y" naming pattern and extract the material after "Support for". The frontend also prefers the corrected parsed material over the storedfilament_type(which may have been saved with the old parser during import). - Firmware Check Shows Wrong Version for H2D Pro (#584) — H2D Pro printers showed firmware as out of date because the firmware check matched against the H2D firmware track instead of the H2D Pro track. The firmware check's model-to-API-key mapping only had display names (e.g., "H2D", "H2D Pro") but not SSDP device codes (e.g., "O1E", "O2D"). Added all known SSDP model codes to the firmware check mapping so raw device codes resolve to the correct firmware track.
- Spurious Error Notifications During Normal Printing (0300_0002) — Some firmware versions send non-zero
print_errorvalues in MQTT during normal printing (e.g.,0x03000002→ short code0300_0002). Theprint_errorparser treated any non-zero value as a real error, appending it tohms_errorsand triggering notifications — even though the printer was printing fine. All known real HMS error codes have their low 16 bits >=0x4000(0x4xxx= fatal,0x8xxx= warning/pause,0xCxxx= prompt). Values below0x4000are status/phase indicators, not faults. Now skips values where the error portion is below0x4000in both theprint_errorandhmsarray parsers. - Spool Auto-Assign Fails With Greenlet Error (#612) — RFID spool auto-assignment logged
WARNING greenlet_spawn has not been called; can't call await_only() hereand silently failed. TheSpool.assignmentsrelationship was never eagerly loaded: whenauto_assign_spool()created a newSpoolAssignmentand calleddb.add(), SQLAlchemy resolved the FK back-populates synchronously (outside the async greenlet), triggering a lazy load on the uninitializedspool.assignmentscollection. The previous fix only coveredspool.k_profiles. Now also initializesspool.assignments = []on newly created spools increate_spool_from_tray(), and addsselectinload(Spool.assignments)to both queries inget_spool_by_tag()for existing spools. Addedexc_info=Trueto the error handlers for full tracebacks in future logs. - SpoolBuddy Link Tag Missing tag_type — Linking an NFC tag to a spool via the SpoolBuddy dashboard's "Link to Spool" action only set
tag_uidbut lefttag_typeanddata_originempty, because it called the genericupdateSpoolAPI instead of the dedicatedlinkTagToSpoolendpoint. The printer card'sLinkSpoolModalalready usedlinkTagToSpoolcorrectly. Now useslinkTagToSpoolwithtag_type: 'generic'anddata_origin: 'nfc_link', which also handles conflict checks and archived tag recycling. - SpoolBuddy AMS Page Missing Fill Levels for Non-BL Spools — AMS slots with non-Bambu Lab spools assigned to inventory didn't show fill level bars on the SpoolBuddy AMS page, even though the main printer card displayed them correctly. The SpoolBuddy AMS page only used the MQTT
remainfield (which is -1/unknown for non-BL spools), while the printer card had a fallback chain: Spoolman → inventory → AMS remain. Now fetches inventory spool assignments and computes fill levels from(label_weight - weight_used) / label_weight, falling back to AMS remain when no inventory assignment exists. - SpoolBuddy AMS Page Ext-R Slot Falsely Shown as Active When Idle — On dual-nozzle printers (H2D), the Ext-R slot was incorrectly highlighted as active when the printer was idle. The ext-R tray has
id=255, and the idle sentineltray_now=255matched it viatrayNow === extTrayId. The main printer card avoided this by clearingeffectiveTrayNowtoundefinedwhentray_now=255. Now guards againsttray_now=255before any ext slot active check. - Printer Card Loses Info When Print Is Paused (#562) — When a print was paused (via G-code pause command or user action), the printer card showed the print as finished — the progress bar, print name, ETA, layer count, and cover image all disappeared, replaced by the idle "Ready to Print" placeholder. The display conditions only checked for
state === 'RUNNING'but not'PAUSE', even though other parts of the same page (Skip Objects button, Stop/Resume controls) already handled both states correctly. Now shows print progress info for bothRUNNINGandPAUSEstates, and the status label correctly reads "Paused" instead of the hardcoded "Printing" fallback. - SpoolBuddy "Assign to AMS" Slot Shows Empty Fields in Slicer — After assigning a spool to an AMS slot via SpoolBuddy's "Assign to AMS" button, the slicer's slot overview showed the correct filament, but opening the slot detail card showed all fields empty/unselected. Two bugs: (1) the
assign_spoolbackend called the cloud API with the rawslicer_filamentvalue including its version suffix (e.g.,PFUS9ac902733670a9_07), which returned a 404; the silent fallback sent thesetting_idastray_info_idxinstead of the realfilament_id(e.g.,PFUS9ac902733670a9instead ofP4d64437), and the slicer couldn't resolve the preset; (2) noSlotPresetMappingwas saved, so Bambuddy's own ConfigureAmsSlotModal couldn't identify the active preset when reopened. Now strips version suffixes before the cloud lookup, resolves the realfilament_idvia the cloud API (with local preset and generic ID fallbacks), includes the brand name intray_sub_brands, and saves the slot preset mapping from the frontend after assignment. - Virtual Printer Bind Server Fails With TLS-Enabled Slicers (#559) — BambuStudio uses TLS on port 3002 for certain printer models (e.g. A1 Mini / N1), but the bind server only spoke plain TCP on both ports 3000 and 3002. The slicer's TLS ClientHello was rejected as an "invalid frame", preventing discovery and connection entirely. Port 3002 now uses TLS (using the VP's existing certificate), while port 3000 remains plain TCP for backwards compatibility. The proxy-mode bind proxy was also updated to use TLS termination on port 3002.
- Queue Returns 500 When Cancelled Print Exists (#558) — When a print was cancelled mid-print, the MQTT completion handler stored status
"aborted"on the queue item, but the response schema only accepts"pending","printing","completed","failed","skipped", or"cancelled". Listing all queue items hit a Pydantic validation error on the invalid status, returning a 500 error. Filtering by a specific status (e.g. "pending") excluded the bad row and worked fine. Now normalises"aborted"to"cancelled"before storing. A startup fixup also converts any existing"aborted"rows. - Tests Send Real Maintenance Notifications — Tests that call
on_print_complete(status="completed")created backgroundasynciotasks (maintenance check, smart plug, notifications) that outlived the test's mock context. When the event loop processed these orphaned tasks,async_sessionwas no longer patched and they queried the real production database — finding real printers with maintenance due and real notification providers, then sending real notifications. Tests now cancel spawned background tasks before the mock context exits. - Virtual Printer Config Changes Ignored Until Toggle Off/On — Changing a virtual printer's mode (e.g. proxy → archive), model, access code, bind IP, remote interface IP, or target printer via the UI updated the database but the running VP instance was never restarted.
sync_from_db()skipped any VP whose ID was already in the running instances dict without checking if config had changed. Now compares critical fields between the running instance and DB record and restarts the VP when a difference is detected. - Sidebar Navigation Ignores User Permissions — All sidebar navigation items (Archives, Queue, Stats, Profiles, Maintenance, Projects, Inventory, Files) were visible to every user regardless of their role's permissions. Only the Settings item was permission-gated. Now each nav item is hidden when the user lacks the corresponding read permission (e.g.,
archives:read,queue:read,library:read). The Printers item remains always visible as the home page. Also added the missinginventory:read|create|update|deletepermissions to the frontend Permission type (they existed in the backend but were absent from the frontend type definition). - Camera Button Clickable Without Permission & ffmpeg Process Leak (#550) — Two camera issues in multi-user environments (e.g., classrooms with multiple printers). First, the camera button on the printer card was clickable even when the user's role lacked
camera:viewpermission. Now disabled with a permission tooltip, matching the existing pattern forprinters:controlon the chamber light button. Second, ffmpeg processes (~240MB each) were never cleaned up after closing a camera stream. Thestop_camera_streamendpoint calledterminate()but neverwait()ed orkill()ed, and HTTP disconnect detection in the streaming response only checked between frames — if the generator was blocked reading from ffmpeg stdout, disconnect was never detected (due to TCP send buffer masking the closed connection). Three fixes: (1) the stop endpoint now usesterminate()→wait(2s)→kill()→wait(); (2) each stream gets a background disconnect monitor task that pollsrequest.is_disconnected()every 2 seconds independently of the frame loop, directly killing the ffmpeg process on disconnect; (3) a periodic cleanup (every 60s) scans/procfor any ffmpeg process with a Bambu RTSP URL (rtsps://bblp:) that isn't in an active stream andSIGKILLs it — catching orphans that survive app restarts or generator abandonment. - Windows Install Fails With "Syntax of the Command Is Incorrect" (#544) — The
start_bambuddy.batPython hash verification used a multi-linefor /f "usebackq"with a backtick-delimited command split across lines. Windows CMD cannot parse line breaks inside backtick-delimitedfor /fcommands, causing "The syntax of the command is incorrect" immediately after downloading Python. The entire block was also redundant — it downloaded a separate checksum file from python.org and re-verified the hash, butverify_sha256had already checked the archive against the pinned hash on the previous line. Removed the duplicate verification block. Also had a secondary bug: always downloaded theamd64checksum even onarm64systems. - Queue Badge Shows on Incompatible Printers (#486) — The purple queue counter badge in the printer card header showed on all printers of the same model when a job was scheduled for "any [model]", even if the printer didn't have the matching filament color loaded. The
PrinterQueueWidget(which shows "Clear Plate & Start") already filtered by filament type and color, but the badge count used the raw unfiltered queue length. Now applies the same filament compatibility filter to the badge count. - SpoolBuddy Daemon Can't Find Hardware Drivers — The daemon's
nfc_reader.pyandscale_reader.pyimportread_tagandscale_diagas bare modules, but these files live inspoolbuddy/scripts/which isn't on Python's module search path. The systemd service setsWorkingDirectorytospoolbuddy/and runspython -m daemon.main, so only thespoolbuddy/anddaemon/directories are onsys.path. Addedscripts/tosys.pathat daemon startup, resolved relative to the module file so it works regardless of install path. Also moved theread_tagimport insideNFCReader.__init__'s try/except block — it was previously outside, so a missing module crashed the entire daemon instead of gracefully skipping NFC polling. Demoted hardware-not-available log messages from ERROR to INFO since missing modules are expected when hardware isn't connected. - SpoolBuddy Scale Tare & Calibration Not Applied — The SpoolBuddy scale tare and calibrate buttons on the Settings page queued commands but never executed them. Five bugs in the chain: (1) the daemon received the
tarecommand via heartbeat but never calledscale.tare()— a comment said "need cross-task communication" but the ScaleReader was already available in the shared dict; (2) no API endpoint existed for the daemon to report the new tare offset back to the backend database, so tare results were lost; (3) when calibration values changed in heartbeat responses, the daemon updated its config object but never calledscale.update_calibration(), so the ScaleReader kept using its initial values forever; (4) the heartbeat response that delivered the tare command still contained pre-tare calibration values, which immediately overwrote the new tare offset back to zero; (5) theset-factorendpoint computedcalibration_factorusing the DBtare_offset, which could be stale or zero if the tare hadn't persisted yet — producing a wildly wrong factor (e.g., 5000g displayed with empty scale). Added aPOST /devices/{device_id}/calibration/set-tareendpoint andupdate_tare()API client method. The heartbeat loop now executesscale.tare()when the tare command is received, persists the result via the new endpoint, propagates calibration changes to the ScaleReader instance, and skips calibration sync on the heartbeat cycle that delivers a tare command. The calibration flow now captures the raw ADC at tare time and sends it alongside the loaded-weight ADC in step 2, so the factor is computed from the actual tare reference rather than the DB value — making calibration self-contained and independent of the tare persistence round-trip. The calibration weight input uses a compact touch-friendly numpad since the RPi kiosk has no physical keyboard. - A1 Mini Shows "Unknown" Status After MQTT Payload Decode Failure (#549) — Some printer firmware versions (observed on A1 Mini 01.07.02.00) occasionally send MQTT payloads containing non-UTF-8 bytes. The
_on_messagehandler calledmsg.payload.decode()(strict UTF-8), and the resultingUnicodeDecodeErrorwas not caught — onlyjson.JSONDecodeErrorwas handled. The entire message was silently dropped, causing printer status to show "unknown", temperatures to read 0°C, and AMS data to disappear. Now catchesUnicodeDecodeErrorand falls back todecode(errors="replace"), which substitutes invalid bytes with U+FFFD while keeping the JSON structure intact. Logs a warning for diagnostics. - H2C Dual Nozzle Variant (O1C2) Not Recognized (#489) — The H2C dual nozzle variant reports model code
O1C2via MQTT, but onlyO1Cwas in the recognized model maps. This caused the camera to use the wrong protocol (chamber image on port 6000 instead of RTSP on port 322) — the printer immediately closed the connection, producing a reconnect loop. Also affected model display names, chamber temperature support detection, linear rail classification, and virtual printer model mapping. AddedO1C2to all model ID maps across backend and frontend. - Support Package Leaks Full Subnet IPs and Misdetects Docker Network Mode — Three support package fixes. First, the network section included full subnet addresses (e.g.,
192.168.192.0/24); now masks the first two octets (x.x.192.0/24). Second,network_mode_hintusedlen(interfaces) > 2which always reported "bridge" on single-NIC hosts even withnetwork_mode: host, becauseget_network_interfaces()excludes Docker infrastructure interfaces. Now checks for the presence of Docker interfaces (docker0,br-*,veth*) viasocket.if_nameindex()— these are only visible when the container shares the host network namespace. Third,developer_modewas still null for most users because the MQTTfunfield was only parsed inside theprintkey; some firmware versions send it at the top level of the payload. Now also checks top-levelfun. Also added avirtual_printerssection with mode, model, enabled/running status, and pending file count for each configured virtual printer. - SpoolBuddy Scale Calibration Lost After Reboot — The SpoolBuddy daemon generated its device ID from the MAC address of whichever network interface
Path.iterdir()returned first, but filesystem iteration order is non-deterministic. On different boots, the daemon could picketh0(MAC ending3100) orwlan0(MAC ending3102), producing a differentdevice_ideach time. Since calibration values (tare_offset,calibration_factor) are stored per device ID in the backend database, a new ID meant registering as a brand-new uncalibrated device. Fixed by sorting network interfaces alphabetically before selection, ensuring the same interface (and thus the same device ID) is always chosen. - SpoolBuddy NFC Reader Fails to Detect Tags — The PN5180 NFC reader had two polling issues. First, each
activate_type_a()call that returnedNone(no tag) corrupted the PN5180 transceive state — subsequent calls silently failed even when a tag was physically present, making it impossible to detect tags placed after startup (only tags already on the reader during init were detected). Fixed by performing a full hardware reset (RST pin toggle + RF re-init, ~240ms) before every idle poll, giving a ~1.8 Hz effective poll rate. Second, after a successful SELECT the card stayed in ACTIVE state and ignored subsequent WUPA/REQA, causing false "tag removed" events after ~1 second. Fixed with a light RF off/on cycle (13ms) before each poll when a tag is present, resetting the card to IDLE for re-selection. Also added error-based auto-recovery (full hardware reset after 10 consecutive poll exceptions), periodic status logging every 60 seconds, and accurate heartbeat reporting of NFC/scale health.
Improved
- SpoolBuddy AMS Page Single-Slot Card Layout — AMS-HT and external spool cards on the SpoolBuddy AMS page now use a responsive grid (2 cards per AMS card width) instead of auto-sized flex items, so they align with the regular AMS card columns above. Regular AMS cards no longer stretch vertically to fill available space on printers with fewer AMS units.
- SpoolBuddy Scale Value Stabilization — The SpoolBuddy daemon now suppresses redundant scale weight reports: only sends updates when the weight changes by ≥2g. Previously every 1-second report interval sent a reading regardless of change, and stability state flips (stable ↔ unstable) also triggered reports — when ADC noise kept the spread hovering around the 2g stability threshold, the flag toggled every cycle, forcing a report with a slightly different weight each time. Removed stability flipping as a report trigger (the stable flag is still included in each report for consumers). Also increased the NAU7802 moving average window from 5 to 20 samples (500ms → 2s) to smooth ADC noise. The frontend also applies a 3g display threshold as defense-in-depth.
- SpoolBuddy TopBar: Online Printer Selection — The printer selector in the SpoolBuddy top bar now only shows online printers and auto-selects the first online printer. If the currently selected printer goes offline, it automatically switches to the next available online printer. Also replaced the placeholder icon with the SpoolBuddy logo. Renamed the connection status label from "Online" to "Backend" for clarity.
- SpoolBuddy Assign to AMS Redesign — The "Assign to AMS" sub-modal (opened from the spool card) is now a full-screen overlay that reuses the
AmsUnitCardcomponent from the AMS page. Regular AMS units display in a 2-column grid with the same spool visualization, fill bars, and material labels. AMS-HT and external slots (Ext / Ext-L / Ext-R on dual-nozzle printers) appear in a compact horizontal row below. Clicking any slot auto-configures the filament via a singleassignSpoolAPI call — the backend handles both the DB assignment and MQTT configuration. The printer selector was removed from the modal since the top bar already provides printer selection. Dual-nozzle printers show L/R nozzle badges on each AMS unit. - Filament ID Conversion Utility — Extracted filament_id ↔ setting_id conversion logic into a shared utility (
backend/app/utils/filament_ids.py). Theassign_spoolendpoint now normalizesslicer_filament(which can be stored in either filament_id format like "GFL05" or setting_id format like "GFSL05_07") into the correcttray_info_idxandsetting_idfor the MQTT command. Previouslysetting_idwas always sent as empty string, which could cause BambuStudio to not resolve the filament preset for the AMS slot. - Updates Card Separates Firmware and Software Settings — The Updates card on the Settings page mixed printer firmware and Bambuddy software update toggles with no visual grouping. Now splits the card into two labeled sections ("Printer Firmware" and "Bambuddy Software") separated by a divider, making it clear which toggles control what.
- SpoolBuddy Test Coverage — Added integration tests for all 12 SpoolBuddy API endpoints (21 backend tests covering device registration/re-registration, heartbeat status and pending commands, NFC tag scan/match/removal, scale reading broadcast, spool weight calculation, and scale calibration including tare, set-factor, and zero-delta error handling) and component tests for the three main SpoolBuddy frontend components (20 frontend tests covering WeightDisplay weight formatting and status indicators, SpoolInfoCard spool info rendering and action callbacks, UnknownTagCard tag display, and TagDetectedModal open/close/escape behavior with known and unknown spool views).
- Cleanup Obsolete Settings — The startup migration now deletes orphaned settings keys from the database that are no longer used by the application (e.g.,
slicer_binary_pathfrom earlier slicer integration research). - Added HUF Currency (#579) — Added Hungarian Forint (HUF, Ft) to the supported currencies list for filament cost tracking.
- FTP Upload Progress & Speed — Reduced FTP upload chunk size from 1MB to 64KB for smoother progress reporting — at typical printer FTP speeds (~50-100KB/s) the progress bar now updates roughly every second instead of appearing stuck for 20+ seconds between jumps. Removed the post-upload
voidresp()wait for all printer models (previously only skipped for A1); H2D printers delay the FTP 226 acknowledgment by 30+ seconds after data transfer completes, causing a long hang at 100%. The data is already on the SD card once the transfer finishes. Also added transfer speed logging (KB/s) and PASV+TLS handshake timing to help diagnose slow connections. - Wider Print & Schedule Modals — Increased the Print and Schedule Print modal width from 512px to 672px to better accommodate long filament profile names (e.g., "PLA Support for PETG PETG Basic @Bambu Lab H2D 0.4 nozzle").
[0.2.1.1] - 2026-02-28
Fixed
- H2C Dual Nozzle Variant (O1C2) Not Recognized (#489) — The H2C dual nozzle variant reports model code
O1C2via MQTT, but onlyO1Cwas in the recognized model maps. This caused the camera to use the wrong protocol (chamber image on port 6000 instead of RTSP on port 322) — the printer immediately closed the connection, producing a reconnect loop. Also affected model display names, chamber temperature support detection, linear rail classification, and virtual printer model mapping. AddedO1C2to all model ID maps across backend and frontend. - Sidebar Navigation Ignores User Permissions — All sidebar navigation items (Archives, Queue, Stats, Profiles, Maintenance, Projects, Inventory, Files) were visible to every user regardless of their role's permissions. Only the Settings item was permission-gated. Now each nav item is hidden when the user lacks the corresponding read permission (e.g.,
archives:read,queue:read,library:read). The Printers item remains always visible as the home page. Also added the missinginventory:read|create|update|deletepermissions to the frontend Permission type (they existed in the backend but were absent from the frontend type definition). - Camera Button Clickable Without Permission & ffmpeg Process Leak (#550) — Two camera issues in multi-user environments (e.g., classrooms with multiple printers). First, the camera button on the printer card was clickable even when the user's role lacked
camera:viewpermission. Now disabled with a permission tooltip, matching the existing pattern forprinters:controlon the chamber light button. Second, ffmpeg processes (~240MB each) were never cleaned up after closing a camera stream. Thestop_camera_streamendpoint calledterminate()but neverwait()ed orkill()ed, and HTTP disconnect detection in the streaming response only checked between frames — if the generator was blocked reading from ffmpeg stdout, disconnect was never detected (due to TCP send buffer masking the closed connection). Three fixes: (1) the stop endpoint now usesterminate()→wait(2s)→kill()→wait(); (2) each stream gets a background disconnect monitor task that pollsrequest.is_disconnected()every 2 seconds independently of the frame loop, directly killing the ffmpeg process on disconnect; (3) a periodic cleanup (every 60s) scans/procfor any ffmpeg process with a Bambu RTSP URL (rtsps://bblp:) that isn't in an active stream andSIGKILLs it — catching orphans that survive app restarts or generator abandonment. - Windows Install Fails With "Syntax of the Command Is Incorrect" (#544) — The
start_bambuddy.batlauncher had Unix (LF) line endings instead of Windows (CRLF). When a user's git config hascore.autocrlf=falseorinput, the file is checked out with LF endings andcmd.execannot parse it. Added a.gitattributesfile that forces CRLF for all.batfiles regardless of git config. - Queue Badge Shows on Incompatible Printers (#486) — The purple queue counter badge in the printer card header showed on all printers of the same model when a job was scheduled for "any [model]", even if the printer didn't have the matching filament color loaded. The
PrinterQueueWidget(which shows "Clear Plate & Start") already filtered by filament type and color, but the badge count used the raw unfiltered queue length. Now applies the same filament compatibility filter to the badge count. - A1 Mini Shows "Unknown" Status After MQTT Payload Decode Failure (#549) — Some printer firmware versions (observed on A1 Mini 01.07.02.00) occasionally send MQTT payloads containing non-UTF-8 bytes. The
_on_messagehandler calledmsg.payload.decode()(strict UTF-8), and the resultingUnicodeDecodeErrorwas not caught — onlyjson.JSONDecodeErrorwas handled. The entire message was silently dropped, causing printer status to show "unknown", temperatures to read 0°C, and AMS data to disappear. Now catchesUnicodeDecodeErrorand falls back todecode(errors="replace"), which substitutes invalid bytes with U+FFFD while keeping the JSON structure intact. Logs a warning for diagnostics.
[0.2.1] - 2026-02-27
Fixed
- Timezone-Aware Datetime Comparisons Crash With SQLite — The 0.2.1 timezone fix (
datetime.now(timezone.utc)) produced aware datetimes, but SQLAlchemy's SQLiteDateTimecolumns return naive datetimes on read. Any Python-side comparison between the two raisedTypeError: can't subtract offset-naive and offset-aware datetimes, crashing the maintenance overview endpoint and potentially 7 other code paths (API key expiration, smart plug auto-off, power alert cooldown, runtime tracking, print scheduling, and timelapse matching). Addedtzinfo is Noneguards before all database datetime comparisons. - FTP Proxy Cannot Bind to Port 990 in Docker — The
cap_add: NET_BIND_SERVICEin docker-compose.yml didn't reliably propagate to the Python process when running as a non-root user (user:directive), depending on the container runtime's ambient capability support. Now sets the file capability directly on the Python binary in the Dockerfile viasetcap, which the kernel honors regardless of runtime configuration. - AMS History Chart Shows Wrong Time Range (#535) — The AMS temperature/humidity chart X axis was fitted to only the data points present (
dataMin/dataMax), not the selected time window. When the printer was offline for part of the period, shorter views (e.g., 6h) appeared compressed to only the portion with data (e.g., 1.5h). Now pins the X axis domain to the full requested time range (e.g., now−6h to now), pads the data edges so the line extends across the full window, and connects through null values so the chart always shows a continuous line. - "Clear Plate & Start Next" Ignores Filament Override Color (#486) — When a print was queued to "any printer" with a filament color override (e.g., white PETG), the "Clear Plate & Start Next" button appeared on all printers of the matching model that had the correct filament type, regardless of color. A printer with blue PETG would show the button for a white PETG job. The backend scheduler already correctly rejected color mismatches, but the frontend
PrinterQueueWidgetonly checkedrequired_filament_types(type only) and ignoredfilament_overrides(type + color). Now passes loaded filament type+color pairs from AMS/vt_tray status to the widget and filters queue items against override colors, mirroring the backend's_count_override_color_matches()logic. - Queue Empty After Container Restart Due to Uncheckpointed WAL (#523) — The print queue appeared empty after a Docker container restart until a filter was applied. SQLite WAL mode keeps uncommitted data in a separate
-walfile, but the shutdown handler never checkpointed the WAL back into the main database or disposed of engine connections. If the container was stopped or crashed, the WAL could contain partial schema migrations or uncommitted data, causing inconsistent query results on restart. Deleting the-waland-shmfiles was the only workaround. Now runsPRAGMA wal_checkpoint(TRUNCATE)and disposes the engine on shutdown, ensuring all data is flushed to the main database file before exit. - Virtual Printer Queue Sends Wrong Plate ID and Ignores AMS Mapping (#529) — Files sent to a virtual printer in queue mode had two issues. First,
plate_idwas always1, generating the wrong MQTT gcode path for multi-plate 3MF files (HMS error 0500_4003). Now extracts the plate index from the 3MF'sslice_info.config. Second,ams_mappingwas never computed for printer-specific queue items (VP assigned to a particular printer), so the printer always used the first AMS slot regardless of which filament the 3MF required. The scheduler now computes AMS mapping for all queue items that lack one, not just model-based assignments. - Unnecessary Target Model Selector on "Any" Tab (#528) — When scheduling a print to "Any {model}", a redundant "Target Model" dropdown appeared even though the G-code is already sliced for a specific printer model. Changing the target model would lead to print failures. The dropdown is now hidden when the sliced model is known (the tab label already shows "Any {model}"). It still appears as a fallback for legacy files without model metadata.
- "Clear Plate & Start Next" Button Shown on Printers Without Correct Filament (#527) — When a print job was queued for "any printer" of a model (e.g., "any H2S"), the "Clear Plate & Start Next" button appeared on ALL printers of that model, including those without the required filament loaded. Clicking it on a printer without the right filament would start a print that fails. The
PrinterQueueWidgetnow filters queue items by filament compatibility — it checks the printer's loaded filament types (from AMS and external spools) against the queue item'srequired_filament_typesand only shows items the printer can actually print. If no compatible items exist, the widget is hidden. - Manual Spool Weight Overwritten by AMS Auto-Sync (#525) — When a user manually entered a spool weight (via UI or API), the value was overwritten by the automatic AMS remain% sync that runs on every MQTT update. The AMS remain% is integer-only (~10g resolution for 1kg spool) and can't match precise manual entries. Added a
weight_lockedflag that is automatically set whenweight_usedis explicitly updated via the API. Locked spools are skipped by both the automatic AMS remain% sync and the manual force-sync endpoint. The usage tracker (3MF/gcode delta tracking) is unaffected. Users can re-enable AMS sync by settingweight_locked: false. - Inconsistent Print Cost on Reprints (#505) — Reprinting the same model produced different costs each time (e.g., £0.77, £1.54, £2.03 for the same print). Three independent code paths wrote to
archive.costwith conflicting strategies: the usage tracker summed ALL historicalSpoolUsageHistoryrows for the archive (including rows from previous reprints), and a separateadd_reprint_costmethod added yet another full print's cost on top. Removed the redundantadd_reprint_costpath entirely and changed the usage tracker to compute cost only from the current print session's results instead of querying all historical rows.archive.costnow always reflects the cost of a single print. - Timestamps Off by Timezone Offset in Non-UTC Docker Containers (#504) — All backend timestamps used
datetime.now()(server local time) or the deprecateddatetime.utcnow(). The frontend'sparseUTCDate()assumes timestamps without timezone indicators are UTC and appends'Z', so when the container's timezone wasn't UTC, every stored timestamp was off by the timezone offset. Replaced all database and comparison timestamps withdatetime.now(timezone.utc)across 16 backend files (~80 call sites). On the frontend, replaced 13new Date(backendTimestamp)calls withparseUTCDate()across 8 files to correctly interpret UTC timestamps. Cosmetic timestamps (filenames, user-facing local time formatting) are intentionally left as local time. - "Power Off Printer" Option Not Gated by Control Permission (#500) — The "Power off printer when done" checkbox in the print modal and the auto power off toggle in the bulk edit modal were accessible to all users regardless of permissions. Users without the
printers:controlpermission can now no longer enable auto power off — the checkbox and tri-state toggle are disabled and visually dimmed. - Created Admin Users Can't See Settings Button (#503) — The sidebar hid the Settings link based on a hardcoded
role === 'user'check instead of the actualsettings:readpermission, so newly created admin users who had the permission still couldn't see the button. Also, after login the auth state was set directly from the login response instead of re-fetching the full auth status, which could miss permission data. Now useshasPermission('settings:read')for the sidebar check and callscheckAuthStatus()after login to load the complete user state including permissions. - "Open in Slicer" Fails for Filenames Containing Special Characters — Filenames with
/,\,?, or#(e.g.,Abzweigdose/Verteilerdose 70mm) caused the slicer protocol handler to fail. The filename is placed in the download URL path andencodeURIComponent-encoded, but BambuStudio and OrcaSlicer callurl_decode()on the entire protocol handler URL before downloading. This decoded%2Fback to/, creating extra path segments that resulted in a 404. The URL filename is purely cosmetic (the backend resolves files by archive ID, not filename), so now sanitizes/,\,?, and#to_in slicer download URLs. - "Queue to Any Printer" Ignores Filament Color Override (#486) — When scheduling a print to "any printer" with a filament color override, the scheduler picked a printer with the correct filament type but wrong color.
_find_idle_printer_for_model()validated only filament type (via_get_missing_filament_types()), while color matching (_count_override_color_matches()) was used only for ranking candidates, not filtering them. A printer with 0 color matches was still selected if it had the right types. Now requires at least 1 color match when filament overrides specify colors — printers with 0 matches are skipped and added to the "waiting for filament" reason instead of being treated as valid candidates. - Virtual Printer Queue Mode Doesn't Assign Printer (#518) — Files sent to a virtual printer in "print queue" mode were added to the queue with no printer assigned, requiring manual assignment. The
_add_to_print_queue()method always created queue items withprinter_id=Noneand notarget_model. Now assigns the virtual printer'starget_printer_idif configured, or falls back to the VP's model (e.g., P1S, X1C) astarget_modelfor "Any Printer" scheduling. - Settings Text Fields Reset While Typing — Text input fields on the Settings page (MQTT broker hostname, HA URL, tokens, etc.) reset mid-typing because the auto-save
onSuccesshandler overwrotelocalSettingswith the server response, discarding characters typed during the save request. Removed the stale state overwrite so in-progress user input is preserved.
Improved
- Queue API Returns More Print Metadata (#524) — The
GET /api/v1/queueandGET /api/v1/queue/{id}endpoints now includefilament_type,filament_color,layer_height,nozzle_diameter, andsliced_for_modelfrom the archive or library file. Previously these fields were only available via the archive endpoints, requiring an extra API call. - Spool Form Profile Dropdown Truncates Long Names (#534) — Long filament profile names (e.g., "Polymaker Panchroma Matte PLA 0.4 nozzle P1S") were truncated in the spool creation form's preset dropdown because filament ID codes displayed alongside each name consumed horizontal space. Removed the inline filament codes from dropdown items (the selected code is still shown below the input after selection) and widened the modal from
max-w-lgtomax-w-xlto give profile names more room.
[0.2.1b3] - 2026-02-23
Fixed
- Print Bed Cooled Notification Never Triggers (#497) — The bed cooldown monitor (which polls bed temperature after a print and sends a notification when it drops below the configured threshold) was defined at the end of the
on_print_completecallback, after an earlyreturnthat exits when no archive is found for the print. Prints started from BambuStudio or the printer's touchscreen typically have no archive in Bambuddy, so the function returned before the bed cooldown task was ever created. Moved the bed cooldown monitor to before the archive lookup early-return so it fires for all completed prints regardless of archive state. Also hardened the temperature dict check from truthiness (if status.temperatures:) to type check (isinstance(status.temperatures, dict)) to avoid false negatives on empty dicts. - IP Addresses Not Redacted From Support Bundle Logs — The
_sanitize_log_content()function redacted emails, serials, and credentials but left raw IPv4 addresses in log output. Now adds known printer IPs to the sensitive string list for exact matching, and applies an IPv4 regex that replaces addresses with[IP]while preserving firmware version strings (which use leading-zero octets like01.09.01.00). Updated the system info page privacy disclaimer to list IP addresses as redacted. - "Unknown stage (74)" on H2D During Print Preparation — The H2D firmware reports
stg_cur=74during print preparation, but this stage was not in the stage name lookup table (which went up to 66, sourced from BambuStudio). Now maps stage 74 to "Preparing". Also added stage 77 ("Preparing AMS") which was present in BambuStudio but missing from the lookup. - Wrong Documentation Link for "Lubricate Carbon Rods" on P2S (#490) — The "Lubricate Carbon Rods" maintenance task linked to the belt tension wiki page instead of the XYZ axis lubrication page for P2S printers.
- External Spool Mapping Inverted on H2C (#492) — On H2C dual-nozzle printers, printing from the right nozzle's external spool (Ext-R) incorrectly highlighted the left external spool (Ext-L) as active. The H2C firmware reports
tray_now=254generically for both external spools, so the frontend's direct ID comparison (effectiveTrayNow === extTrayId) always matched Ext-L (id=254). Now usesactive_extruderon dual-nozzle printers to determine which external spool is active: extruder 1 (left) → Ext-L, extruder 0 (right) → Ext-R. - External Spool Assignments Lost on Restart (#493) — Filament spool assignments on external spool holders (Ext-L / Ext-R) were silently deleted every time AMS data changed, including on container restart. The
on_ams_changestale-assignment cleanup searched only AMS unit data for matching trays, but external spools live invt_tray(a separate MQTT field). Since_find_tray_in_ams_datanever found them, external assignments were always marked as stale and removed. Now looks up external spool assignments (ams_id=255) in the printer'svt_traydata instead, and keeps the assignment ifvt_traydata hasn't arrived yet. - Developer Mode Detection Always Reports Null — The MQTT
funfield is an integer in the JSON payload, but the parser usedint(value, 16)which requires a string argument. This raisedTypeErroron every message, silently caught by the exception handler, sodeveloper_modewas never set. Now handles both integer and hex string formats. - Filament Fill Level Wrong in Hover Card / Missing for External Spools (#496) — Three related fill level display bugs on the printer card. First, external spool slots (vt_tray) were missing the AMS
remainfallback entirely —extEffectiveFillonly checked Spoolman and inventory, falling through tonulleven when the printer reported a valid fill percentage. Now includes the same AMS remain fallback as regular and AMS-HT slots. Second, when fill level was unknown (null), the AMS slot visual showed a full-width gray bar (appearing "full") while the hover card showed "—" (appearing "empty") — confusing users into thinking the printer card and hover card disagreed. Removed the misleading gray fallback bar from all three slot types; the empty fill bar track now consistently indicates "unknown" in both views. Third, the fill level priority chain always preferred AMSremainover Spoolman and inventory data, even when those sources were more accurate (e.g., spools migrated from Spoolman to internal inventory, or spools with accurate usage tracking). Reversed the priority to Spoolman → Inventory → AMS remain, and fixedfillSourceto correctly reflect the actual data source used (was always reporting'ams'even when Spoolman or inventory provided the value via the fallback chain whenremainwas -1). - File Manager Rename Doesn't Update Displayed Name (#460) — Renaming a file in the File Manager updated the
filenamefield but notfile_metadata.print_name, which the UI uses as the primary display name. Sinceprint_nameis extracted from inside the 3MF at upload time, it always took precedence over the renamedfilename. The rename endpoint now also updatesprint_namein the file metadata when present. - Finish Photo Not Captured When Archive Has No Source 3MF (#484) — When a print completed but the 3MF source file wasn't downloaded from the printer (e.g. FTP download failure), the archive's
file_pathwas null. The finish photo capture silently skipped because it derived the save directory fromfile_path. Now falls back toarchive/{id}/so the photo is captured regardless.
New Features
- Filament Override for Model-Based Queue (#486) — When scheduling a print to "any printer" (model-based assignment), you can now override the 3MF's original filament choices. A new section in the print modal shows the filaments required by the sliced file and lets you swap each slot to any compatible filament loaded across printers of the selected model. The scheduler matches against the overridden type and color instead of the original 3MF values, preferring printers with exact color matches. On dual-nozzle printers (H2D), the override dropdown only shows filaments on the correct extruder for each slot. New
GET /printers/available-filamentsendpoint aggregates loaded filaments across all active printers of a given model. Backend stores overrides as a JSON column on the queue item and applies them at scheduling time by merging into filament requirements before AMS mapping. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).
[0.2.1b2] - 2026-02-21
Fixed
- Wrong AMS Unit Displayed With Dual AMS on P2S (#420) — On P2S printers with two AMS units, the UI highlighted the wrong AMS when printing from the second unit (e.g., printing from AMS-B slot 2 but AMS-A slot 2 was shown as active). The P2S firmware sends local slot IDs (0-3) in
tray_now, not global tray IDs — contrary to the previous assumption that all single-nozzle printers report global IDs. Filament usage tracking was unaffected because it uses the MQTTmappingfield (snow-encoded with correct AMS hardware IDs). The display now cross-referencestray_nowwith the MQTT mapping field to resolve the correct AMS unit when multiple AMS units are detected viaams_exist_bits. Falls back to the raw value when no mapping is available (e.g., manual filament load outside of a print) or when the mapping is ambiguous. - PCTG Filament Misidentified as PC (#478) — Selecting "Generic PCTG" as a filament profile defaulted to PC material. The spool form's material parser listed PC before PCTG and used substring matching (
indexOf), so "PCTG" matched "PC" first. The AMS slot configuration and local profiles views were also missing PCTG from their known material types. Additionally, the temperature range logic usedincludes('PC')which matched PCTG and assigned PC temperatures (260-300°C) instead of PETG-range temperatures (220-260°C). Fixed by reordering PCTG before PC in the spool form parser, adding PCTG to all material type arrays, and adding an exact-match temperature case for PCTG. - Phantom Prints From Lingering SD Card Files (#477) — Prints could restart without user input hours after completing, because uploaded gcode files survived on the printer's SD card and were auto-started on firmware restart. Three bugs allowed files to linger. First, the post-print SD card cleanup retry loop always broke after the first attempt regardless of success, because
delete_file_asynccatches errors internally and returnsFalseinstead of raising — theexceptretry branch never executed. Fixed by only breaking on successful delete and retrying with a 2-second delay on failure. Second, whenstart_print()failed after uploading a file (in both the background dispatcher and print scheduler), the uploaded file was never cleaned up sinceon_print_completenever fires for a print that never started. Now deletes the uploaded file on a best-effort basis whenstart_print()returnsFalse. Third, cleanup failure logging was atDEBUGlevel, making failures invisible in normal operation — escalated toWARNING. - Non-Actionable HMS Errors Triggering Notifications (#470) — Infrastructure and auth-related HMS error codes (like
0500_0007"MQTT command verification failed") were triggering printer error notifications even though they don't indicate actual print problems. For example, a device with incorrect bind settings sending unauthorized MQTT commands caused repeated false-alarm nozzle/extruder error notifications with camera snapshots of perfectly fine prints. Now suppresses notifications for known non-actionable error codes:0500_0007(MQTT auth failure),0500_4001(Bambu Cloud connection failure), and0500_400E(print cancelled by user). - Support Bundle Leaking Personal Data (#473) — The support bundle's log sanitizer only used regex patterns, which can't detect arbitrary user-chosen strings like printer names and usernames. Now queries the database for known sensitive values (printer names, serial numbers, auth usernames, Bambu Cloud email) and does exact-string replacement before the regex pass. Serial number regex no longer leaks the first 3 characters (was using a capture group for partial redaction). Tasmota smart plug credentials embedded in URLs (
http://user:pass@host) were logged verbatim by httpx; now uses httpx'sauthparameter for HTTP Basic auth so credentials never appear in the URL. Addedusernameandpathto the settings key filter to redactsmtp_usernameandslicer_binary_pathfrom the support info JSON. A URL credentials regex provides defense-in-depth for any remaininguser:pass@patterns in logs. IP addresses are no longer redacted from the bundle as they are needed for connectivity debugging. Updated the frontend privacy disclaimer and wiki documentation to reflect the new behavior. - Spool Usage Lost When Spool Runs Empty Mid-Print (#459) — When a spool ran empty during a print and the AMS auto-switched to a backup spool, two problems caused incorrect tracking. First, the
on_ams_changehandler eagerly deleted the empty spool'sSpoolAssignmentrecord (fingerprint mismatch), soon_print_completefound nothing and silently dropped usage — fixed by snapshotting all spool assignments at print start into thePrintSession. Second, even with the snapshot fix, the entire print's filament weight was attributed to the original spool (100%/0% split) because_track_from_3mf()only knew about the tray loaded at print start. Now tracks tray changes during the print viatray_change_logonPrinterState, recording each tray switch with its layer number. At print completion, the usage tracker splits the 3MF weight across trays using per-layer gcode data for precise segment boundaries, with a linear layer-ratio fallback when gcode data isn't available. The last segment always receives the remainder to prevent rounding drift. - K-Profile Response Race Condition Crash (#462) — An unsolicited or late K-profile MQTT response could crash the MQTT handler with
AttributeError: 'NoneType' object has no attribute 'set'. The MQTT callback thread checkedself._pending_kprofile_response(not None) at line 2698, but between that check and the.set()call, the asyncio thread'sfinallyblock inget_kprofiles()could clear the attribute toNoneafter a timeout — a classic TOCTOU race. Fixed by capturing the event reference in a local variable before the check. - Queue Stuck on "Busy" for "Any Model" Jobs (#435) — When a print was queued with "Any [Model]" (e.g., "Any P1S"), it was created with
printer_id=NULLandtarget_model="P1S". After the assigned printer finished, the queue widget queried only for items matchingprinter_id=X, missing the next pending model-based item (printer_id IS NULL). With no next item found, the "Clear Plate & Start Next" button never appeared, leaving the scheduler stuck reporting "Busy". The queue API now accepts an optionaltarget_modelparameter; when combined withprinter_id, it uses OR logic to also return unassigned items whosetarget_modelmatches the printer's model. The frontend passes the printer's model through to this query. Additionally, the backend now resolves the printer's model server-side from the database when the frontend doesn't providetarget_model(e.g., when the printer was added without selecting a model), ensuring the OR logic works regardless of whether the client knows the printer's model. - Queue "Any Model" Jobs Stuck in "Waiting" After Plate Clear (#435) — After the queue visibility fix above, "Any Model" jobs were correctly assigned to an idle printer but immediately crashed with
'>=' not supported between instances of 'str' and 'int'when computing AMS filament mapping. MQTT raw data returns AMS unit and tray IDs as strings, but_build_loaded_filaments()compared them to integers without casting. The crash prevented the assignment from committing, so the scheduler retried every 30 seconds in an infinite loop. Castams_idandtray_idtoint()to match the pattern already used for external spool IDs. - SD Card Cleanup After Print Never Runs (#374) — The post-print SD card cleanup (which deletes uploaded gcode from the printer root to prevent phantom prints on power cycle) used
printer_manager.get_printer(), which returns aPrinterInfowith onlynameandserial_number. Accessing.ip_address,.access_code, and.modelraisedAttributeError, silently caught by the outer exception handler. Replaced with a DB query for thePrintermodel, matching the pattern used everywhere else inon_print_complete(). - Finish Photo Not Shown on Archives for BambuStudio Prints (#474) — When a print was started from BambuStudio (not Bambuddy), the auto-archive had an empty
file_path. The finish photo was saved correctly todata/photos/, but the photo serving endpoint resolved the path as(base_dir / "").parent / "photos/"which evaluates tobase_dir.parent/photos/— one directory level too high. The photo existed on disk but the API returned 404. Fixed the path resolution inget_photo,upload_photo, anddelete_phototo usebase_dir / Path(file_path).parent(same pattern as the save code), which correctly resolves tobase_dir/photos/whenfile_pathis empty. - Archive Endpoints Crash With "Is a directory" for BambuStudio Prints (#475) — When a print was started from BambuStudio (not Bambuddy), the 3MF file is transient on the printer and FTP download fails, creating a fallback archive with
file_path="". The archive endpoints usedPath.exists()to check if the 3MF file was available, butsettings.base_dir / ""resolves to the base directory itself — whichexists()reports as True. SubsequentZipFile()calls then failed with[Errno 21] Is a directory. Replaced all.exists()checks on archive file paths with.is_file()across 15 locations in the archive routes and 1 in the main module. Also added afile_pathtruthiness guard for finish photo capture to prevent saving photos under the base directory when the archive has no file path. - AMS Slot Auto-Configuration Falls Back to Generic Instead of Spool's Slicer Preset (#479) — When assigning a spool with a custom slicer preset (e.g., PFUS* cloud-synced profiles from BambuStudio) to an AMS slot, the slot was always configured with a generic Bambu filament ID (e.g., "Generic ABS" / GFB99) instead of the spool's actual preset. Two bugs caused this. First, all PFUS* IDs were blanket-rejected as "user-local IDs unknown to other slicers" and replaced with generic IDs — but PFUS presets are cloud-synced custom profiles that the printer understands. Second, the slot-reuse logic preserved generic fallback IDs (GFB99, GFL99, etc.) as if they were specific presets: once a slot was set to generic, every subsequent same-material assignment reused it, making generic IDs "sticky". Fixed priority order: (1) spool's own
slicer_filamentif set (including PFUS*/P* custom presets), (2) reuse slot's existing preset only if it's a specific non-generic ID for the same material, (3) generic Bambu filament ID as last resort. Bothassign_spoolandconfigure_ams_slotcode paths are fixed. - ntfy Notifications Fail With "Illegal header value" (#466) — When sending ntfy notifications with image attachments (progress, error events), the message body was placed in an HTTP
Messageheader. Multi-line messages (e.g., printer name + remaining time) contain newline characters, which are illegal in HTTP headers. Test notifications worked because they are single-line with no image. Now escapes newlines to literal\nin the header, which ntfy interprets and renders as actual line breaks. Additionally, ntfy servers with attachments disabled rejected thumbnail uploads with "attachments not allowed" (HTTP 400 / code 40014), causing the entire notification to fail. Now automatically retries without the image when the server doesn't support attachments. - Inventory Date Format Ignores Settings (#463) — The inventory page used a local
formatDate()that hardcoded theen-GBlocale, always displaying dates in a fixed format regardless of the date format setting. Now fetches thedate_formatsetting and uses the sharedformatDateInput()utility which formats as MM/DD/YYYY, DD/MM/YYYY, YYYY-MM-DD, or browser locale based on the user's choice. - Inventory Location Shows Garbled Characters for AMS-HT Slots (#463) — The inventory location column computed slot letters via
String.fromCharCode(65 + ams_id), which produced accented characters (e.g.,Á) for AMS-HT units (ams_id ≥ 128). Now uses the sharedformatSlotLabel()utility which correctly handles AMS-HT and external spool slots.
New Features
- Bulk Spool Addition & Stock Spools (#480) — Inventory enhancements for managing large filament collections. Quick Add mode: a toggle on the spool form that shows only material (required), brand, subtype (both optional), color, label weight, and quantity — ideal for inventorying filament without a specific slicer profile ("stock" spools). The quantity field (1–100) only appears in Quick Add mode and creates multiple identical spools in one transaction via
POST /inventory/spools/bulk. Stock spools are computed (no database migration) — any spool without aslicer_filamentis displayed with an amber "Stock" badge. A new filter (All / Stock / Configured) on the inventory page lets you filter by stock status. Group similar spools: a "Group" toggle in the inventory toolbar visually collapses identical unused/unassigned spools into a single expandable row or card with a count badge (e.g., "5 identical spools"). Grouping key uses material, subtype, brand, color, and label weight. Used or AMS-assigned spools always appear individually. Group state persists to localStorage. The Stock column is available but hidden by default in column settings. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR). - Filament Cost Tracking (#454, #452) — Track per-spool filament costs and see cost breakdowns for every print. Each spool can have a
cost_per_kgvalue; when a print completes, the usage tracker calculates the cost from actual filament consumption and stores it in the usage history. Archive costs are automatically aggregated from spool usage records. A globaldefault_filament_costsetting (Settings → Filament) provides a fallback when spools don't have individual costs set. The print modal shows a real-time cost preview based on loaded filaments. Archive cards display the total cost. The inventory table includes a sortable cost/kg column. The recalculate-costs endpoint can retroactively update all archive costs when filament prices change. Contributed by @Keybored02. - Background Print Dispatch (#408, #112) — Printing from archives and the file manager now runs in the background via an async dispatch service. FTP uploads and print-start commands are decoupled from API request latency, so the UI responds immediately. Real-time progress is streamed to all clients via WebSocket, rendered as a persistent toast with per-job upload progress bars, status badges (dispatched/processing/completed/failed/cancelled), and a cancel button. The dispatcher supports concurrent uploads to different printers with per-printer queuing to prevent conflicts. Cancellation is cooperative — uploads abort at the next chunk boundary and clean up partial files on the printer. Batch progress tracking shows overall completion across multi-printer dispatches. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).
- Include Beta Updates Setting — New toggle in Settings → Updates to opt in to beta/prerelease update notifications. Default: off (stable only). The update checker now fetches
/releasesinstead of/releases/latestand filters byparse_version()prerelease detection (not GitHub'sprereleaseflag, which may not be set correctly). Users on the Dockerlatesttag will no longer see notifications for beta releases they can't install. - Developer LAN Mode Detection & Warning Banner — Automatically detects whether connected printers have Developer LAN Mode enabled by parsing the MQTT
funfield (bit0x20000000). When any connected printer lacks developer mode, a persistent orange warning banner appears at the top of the UI with the affected printer name(s) and a link to Bambu Lab's documentation on how to enable it. Without developer mode, MQTT write operations (start/stop/pause prints, AMS control, light/speed/gcode commands) are silently rejected by newer firmware. Thedeveloper_modestate is included in the support bundle for diagnostics. New/printers/developer-mode-warningsendpoint provides a lightweight polling summary. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).
Improved
- Clear Plate Dot Indicator on Sidebar — When the print queue is active and a printer finishes or fails with a pending next job, a small yellow dot now appears on the Printers sidebar icon to signal that user action (clearing the build plate) is needed. The indicator reuses the existing WebSocket-driven printer status cache, so no additional API polling is required. The dot disappears once the plate is cleared or the queue empties.
- Inventory Sidebar Always Visible — The Inventory sidebar item is no longer hidden when Spoolman is enabled. Instead, clicking it embeds the Spoolman web UI in the main content area via iframe (same approach as external links). When Spoolman is disabled, the internal inventory page is shown as before. Both modes use the same
/inventoryroute and sidebar position. - Filament Override Test Coverage — Added 11 backend unit tests: 6 for
_count_override_color_matches(no status, exact match, no match, partial match, color normalization, external spool) and 5 for override application in filament matching (color override, tray_info_idx clearing, type change, partial override, nozzle filtering with override). Added 12 frontend tests for theFilamentOverridecomponent: 5 rendering tests (null guards, slot display, dropdown count), 2 type filtering tests (same-type only, all colors), 3 nozzle filtering tests (extruder_id matching, single-nozzle passthrough, null extruder_id inclusion), and 2 interaction tests (select override, reset to original). - P2S Dual-AMS tray_now Test Coverage — Added 14 integration tests for multi-AMS tray_now disambiguation on single-nozzle printers (resolving AMS-B slots via mapping field, AMS-A passthrough, multi-color mapping, ambiguous/missing mapping fallbacks, last_loaded_tray tracking). Added 9 unit tests for
_resolve_local_slot_from_mapping(snow decoding, unmapped entry filtering, ambiguity detection, AMS-HT slot matching). All 66 tray_now-related tests pass. - Bulk Spool, Stock & Grouping Test Coverage — Added 13 backend unit tests covering
SpoolBulkCreateschema validation (quantity bounds, field preservation, stock vs configured distinction) and bulk endpoint logic (correct spool count, single quantity, identical fields). Added 29 frontend tests: 13 forSpoolFormModalcoveringvalidateFormwithquickAddflag (6 tests), quick-add toggle visibility, PA Profile tab hiding, quantity field gating (hidden by default, visible only in quick-add, hidden in edit mode), and brand/subtype optional asterisk removal in quick-add; 16 for inventory grouping logic coveringspoolGroupKeyidentity/differentiation (7 tests) andcomputeDisplayItemsgrouping rules (9 tests for identical/different/used/assigned/single/order/mixed/empty scenarios). - Filament Cost Tracking Test Coverage — Added 2 backend unit tests for archive cost aggregation (zero-cost guard preserves existing costs, positive-cost updates archive correctly). Added 2 frontend unit tests for spool form cost_per_kg persistence. Fixed missing
archive_iddatabase migration, SQLAlchemyis None→.is_(None)in where clauses, duplicate archive cost write, and unconditional zero-cost overwrite. - Spool Assignment Snapshot Test Coverage — Added 7 backend unit tests covering spool assignment snapshotting at print start, snapshot-preferred spool lookup in both 3MF and AMS delta paths, fallback to live query for pre-upgrade sessions, and the core mid-print unlink scenario from #459.
- Background Dispatch Test Coverage — Added 5 backend unit tests for dispatch cancel races (single-lock TOCTOU fix), batch counter reset re-check, and job lifecycle. Added 2 FTP regression tests for voidresp error handling (upload-loop prevention) and A1 model voidresp skip. Added 1 frontend test for reprint toast suppression.
- Tray Change Split Test Coverage — Added 8 MQTT unit tests for
tray_change_loglifecycle (default empty, seed on print start, clear on new print, record during RUNNING/PAUSE, ignore during IDLE, deduplicate, multi-change history). Added 6 usage tracker unit tests for weight splitting (per-layer gcode split, linear fallback, no-change normal path, empty log recovery, missing spool skip, triple segment split). - Developer Mode Detection Test Coverage — Added 7 backend unit tests for MQTT
funfield parsing (bit clear/set detection, exact bit check, invalid hex handling, state persistence across messages). Added 4 frontend tests for the warning banner (single/multiple printer names, hidden when empty, "How to enable" link). - Frontend Pre-Commit Hooks (#458) — Added
frontend-typecheck(tsc --noEmit) andfrontend-lint(eslint .) hooks to the pre-commit config. Both hooks only trigger whenfrontend/src/**/*.{ts,tsx}files are staged.
[0.2.1b] - 2026-02-19
Fixed
-
PAUSED State Never Matched (#447) — Removed dead
PAUSEDchecks across frontend and backend. The printer only sendsPAUSEvia MQTTgcode_state, soPAUSEDcomparisons were unreachable code. -
Nozzle Mapping Uses Wrong Source in 3MF Files — The
extract_nozzle_mapping_from_3mf()function usedfilament_nozzle_map(user preference) as the primary source for nozzle assignments. BambuStudio's "Auto For Flush" mode overrides user preferences at slice time, so the actual assignment lives in thegroup_idattribute on<filament>elements inslice_info.config. Now usesgroup_idas the primary source and falls back tofilament_nozzle_maponly whengroup_idis not present. -
Print Scheduler Hard-Filters Nozzle When No Trays on Target Nozzle — On dual-nozzle printers, the scheduler enforced a strict nozzle filter when matching filaments. If a slicer filament was assigned to a nozzle with no AMS trays (e.g., only external spool on left nozzle), the match failed even though the filament existed on the other nozzle. Now falls back to unfiltered matching when no trays exist on the target nozzle.
-
Print Scheduler External Spool Ignores Nozzle Assignment — The external spool fallback in the scheduler always mapped to extruder 0 (right), ignoring the slicer's nozzle assignment. Now uses the 3MF nozzle mapping to select the correct extruder for external spool matches.
-
ams_extruder_map Race Condition on Printer Status API — The
/printers/{id}/statusendpoint readams_extruder_mapfrom the MQTT state without checking if the AMS data had been received yet. On fresh connections before the first AMS push-all, this returned an empty map — causing the frontend nozzle filter to show all trays as unfiltered. Now returns an empty object gracefully and the frontend disables nozzle filtering until the map is populated. -
Filament Mapping Frontend Ignores Nozzle for External Spools — The
useFilamentMappinghook always setextruder_id: 0for external spool matches. Now uses the nozzle mapping from the 3MF file to determine the correct extruder. -
AMS-HT Global Tray ID Computed Wrong on Printer Card — The PrintersPage computed AMS-HT tray IDs using
ams_id * 4 + slot(giving 512+), but AMS-HT units use their rawams_id(128-135) as the global tray ID. Now usesams_iddirectly for AMS-HT units. -
Filament Mapping Dropdown Shows Wrong Nozzle Trays — The FilamentMapping dropdown filtered by
extruder_idusing strict equality, butextruder_idcould beundefinedfor printers that hadn't reported their AMS extruder map yet. This caused all trays to be hidden. Now skips nozzle filtering whenextruder_idis undefined. -
Cancelled Print Usage Tracking Uses Stale Progress/Layer — When a print was cancelled, the usage tracker read
mc_percentandlayer_numfrom the printer's MQTT state — but by the time theon_print_completecallback ran, the printer had already reset these to 0. Now captures the last valid progress and layer values during printing, and the usage tracker reads these captured values on cancellation for accurate partial usage. -
H2D Tray Disambiguation Triggers on Single-Nozzle Printers — The
tray_now <= 3check for H2D dual-nozzle disambiguation matched any printer loading from AMS 0 (trays 0-3). On P2S, X1C, and X1E with multiple AMS units, this caused warning log spam every second. Now uses a persistent_is_dual_nozzleflag detected fromdevice.extruder.info(>= 2 entries), which only dual-nozzle printers (H2D, H2D Pro) report. -
AMS-HT Snow Slot Mismatch Log Spam on H2D — The snow-based tray_now disambiguation computed
snow_slot = -1for AMS-HT trays (IDs 128-135), causing a "slot mismatch" debug log on every MQTT update even though the result was correct. Now correctly computessnow_slot = 0for AMS-HT single-slot units. -
H2D Tray Disambiguation Produces Bogus tray_now for AMS-HT (#364) — When the snow field hadn't arrived yet on H2D dual-nozzle printers, the
ams_extruder_mapfallback computedams_id * 4 + slotfor all AMS types — including AMS-HT units (IDs 128-135) which have a single slot and use their unit ID as the global tray ID. This produced bogus values like 512+ that briefly appeared in the UI and could pollutelast_loaded_tray. Now correctly returns the AMS-HT unit ID for single-slot units, handles AMS-HT in multi-AMS matching, filters AMS-HT candidates when slot > 0, and tightenslast_loaded_trayto only accept physically valid tray IDs (0-15, 128-135, 254). -
Color Tooltip Clipped Behind Adjacent Swatches — Color swatch hover tooltips in the spool form were rendered behind neighboring swatches due to missing z-index on the hover state. Added
hover:z-20and tooltipz-20classes. -
Print Queue Shows UUID Hash Instead of Filename (#438) — When printing a library file, the Print Queue and archive displayed the UUID-hex disk filename (e.g.,
c65887535303404eba1525176a0f78dc) instead of the original human-readable name. Library files are stored on disk with UUID filenames for uniqueness, butarchive_print()used the disk path as the display name. Now passes the originalLibraryFile.filenamethrough toarchive_print()from both the print scheduler and the direct-print-from-library flow, so the archive'sfilename,print_name, and directory name all use the human-readable name. -
Usage Tracking Wrong Spool on Dual-Nozzle / Multi-AMS Printers (#364) — On H2C, H2D Pro, and other dual-nozzle printers with multiple AMS units, the usage tracker attributed filament consumption to the wrong spools. The MQTT
mappingfield — a per-print array that maps slicer filament slots to physical AMS trays — was preserved in state but never parsed or used. The tracker fell back toslot_id - 1as the global tray ID, which is incorrect when AMS hardware IDs differ from sequential indices (e.g., AMS-HT units with ID 128). Now decodes the MQTT mapping field from its snow encoding (ams_hw_id * 256 + local_slot) into bambuddy global tray IDs and uses it as a universal mapping source — working for all printer models and all print sources (slicer, queue, reprint) without relying ontray_nowdisambiguation. For printers that don't provide the MQTT mapping field (A1, A1 Mini, P1S, P2S), a color-matching fallback compares 3MF filament slot colors against AMS tray colors to resolve the correct slot-to-tray mapping. Gracefully returns no match when colors are ambiguous (duplicate tray colors) or unavailable. -
AMS Slot Config: PFUS Preset IDs Cause Slicer to Reset Slots — When assigning a spool with a user-local
PFUS*preset ID (from BambuStudio's custom filament profiles), the slicer didn't recognize the ID and actively reset the AMS slot configuration. Now replacesPFUS*IDs with generic Bambu filament IDs (e.g.,GFL99for PLA). When the slot already has a recognized cloud-synced preset for the same material (e.g.,P4d64437), it is reused to preserve K-profile calibration associations. Applies to both the slot configure endpoint and the inventory spool assignment flow. -
Fill Level Bar Missing for Brand New Spools — Spools with
weight_used = 0(brand new, never printed) showed no fill level bar on the printer card. The condition checkedweight_used > 0instead ofweight_used != null, excluding zero-usage spools. Now correctly shows 100% fill for new spools while still hiding the bar when weight data is unavailable (null). -
npm audit: suppress moderate ajv ReDoS finding — Added
audit-level=hightofrontend/.npmrcsonpm auditexits cleanly. The ajv@6 ReDoS (GHSA-2g4f-4pwh-qvx6) is a transitive dependency of eslint@9 with no patched v6 release; ajv@8 override breaks eslint. The vulnerability requires crafted$dataschema input — not an attack vector in a linting config. -
npm audit: fix minimatch ReDoS finding — Added an npm override for
minimatch@^10.2.1inpackage.jsonto resolve the high-severity ReDoS (GHSA-3ppc-4f35-3m26) affecting minimatch@3.x/9.x pulled in transitively by eslint@9, typescript-eslint, and @vitest/coverage-v8. Eslint@9 pins minimatch@3.x with no patched release; eslint@10 upgrades to minimatch@10 but is not yet available. The override forces the patched version across the tree. Verified lint, build, and all tests pass. -
Spool Form Allows Empty Brand & Subtype (#417) — The spool add/edit modal did not require Brand or Subtype fields, allowing spools to be saved without them. When such a spool was assigned to an AMS slot, the
tray_sub_brandssent to the printer was incomplete (e.g., just "PETG" instead of "PETG Basic"), causing BambuStudio to not recognize the filament profile. Brand and Subtype are now mandatory fields with validation errors shown on submit. -
Open in Slicer Fails When Authentication Enabled (#421) — The "Open in Slicer" buttons for BambuStudio and OrcaSlicer failed with "importing failed" when authentication was enabled. Slicer protocol handlers (
bambustudio://,orcaslicer://) launch the slicer app which fetches the file via HTTP — but cannot send authentication headers, so the global auth middleware returned 401. Additionally, the URL format was wrong on Linux (used the macOS-onlybambustudioopen://scheme instead ofbambustudio://open?file=). Fixed with short-lived, single-use download tokens: the frontend fetches a token via an authenticated POST endpoint, then builds a/dl/{token}/{filename}URL that the slicer can access without auth headers. The token is validated server-side (5-minute expiry, single-use). Platform-specific URL formats now match the actual slicer source code: macOS usesbambustudioopen://with URL encoding, Windows/Linux usebambustudio://open?file=, and OrcaSlicer usesorcaslicer://open?file=.
New Features
- Multiple Virtual Printers — Run multiple virtual printers per Bambuddy installation. Each virtual printer gets a dedicated bind IP address with completely independent FTP, MQTT, SSDP, and Bind servers — no shared services or SNI routing. Full CRUD API (
/api/virtual-printers) and React UI for creating, editing, and deleting virtual printers. Each instance supports all four modes (Immediate, Review, Print Queue, Proxy), any of the 11 supported printer models, per-instance TLS certificates (shared CA), and individual network interface override. Database-backed with auto-incremented serial suffixes. - Virtual Printer: Dual Bind/Detect Ports (#445) — The slicer bind/detect handshake now listens on both ports 3000 and 3002. Different BambuStudio/OrcaSlicer versions use different ports for this handshake, so Bambuddy accepts connections on either. Applies to both server mode (BindServer) and proxy mode (SlicerProxyManager).
- Clear Plate Permission (#446) — New
printers:clear_platepermission allows admins to grant users the ability to confirm a plate is cleared for the next queued print without granting fullprinters:control(which also allows stopping prints, configuring AMS, toggling lights, etc.). Existing groups withprinters:controlautomatically receive the new permission on startup. The Operators default group includes it by default. - Full-Page Group Permission Editor (#446) — Replaced the cramped permission modal with a dedicated full-page editor at
/groups/:id/edit. Features a responsive 2-column grid of always-expanded category cards, permission search/filtering, Select All / Clear All bulk actions, category-level checkboxes with partial state, and a fixed bottom action bar. The oldGroupsPage.tsxdead code has been removed.
Changed
- Filament Catalog API Renamed (#427) — Renamed
/api/v1/filaments/to/api/v1/filament-catalog/to avoid confusion with the inventory spools page (labeled "Filament" in the UI). The old endpoint managed material type definitions (cost, temperature, density), not physical spools — the shared name caused users to expect the API to return their spool inventory.
Improved
- AMS Mapping Test Coverage — Added 63 backend tests for scheduler AMS mapping (nozzle filtering, external spool extruder assignment, fallback behavior) and 43 frontend tests for
useFilamentMappinghook (nozzle-aware matching, AMS-HT handling, external spool extruder logic). - Tray Now Disambiguation Test Coverage — Added 28 MQTT message replay tests covering all
tray_nowdisambiguation paths: single-nozzle passthrough (X1E/P2S), H2D dual-nozzle snow field, pending target,ams_extruder_mapfallback, active extruder switching, and full multi-color print lifecycles. - Tray Info Idx Resolution Test Coverage — Added 12 backend integration tests for PFUS→generic tray_info_idx resolution across both the slot configure and inventory assignment endpoints, plus 10 frontend unit tests for the fill level calculation logic.
[0.2.0] - 2026-02-17
New Features
-
Bed Cooled Notification (#378) — New notification event that fires when the print bed cools below a configurable threshold (default 35°C) after a print completes. Useful for knowing when it's safe to remove parts. A background task polls the bed temperature every 15 seconds after print completion and sends a notification when it drops below the threshold. Automatically cancels if a new print starts or the printer disconnects. The threshold is configurable in Settings → Notifications. Includes a customizable notification template with printer name, bed temperature, and threshold variables.
-
Spool Inventory — AMS Slot Assignment — Assign inventory spools to AMS slots for filament tracking. Hover over any non-Bambu-Lab AMS slot to assign or unassign spools. The assign modal filters out Bambu Lab spools (tracked via RFID) and spools already assigned to other slots. Bambu Lab spool slots automatically hide assign/unassign UI since they are managed by the AMS. When a Bambu Lab spool is inserted into a slot with a manual assignment, the assignment is automatically unlinked.
-
Spool Inventory — Remaining Weight Editing — Edit the remaining filament weight when adding or editing a spool. The new "Remaining Weight" field in the Additional section shows current weight (label weight minus consumed) with a max reference. Edits are stored as
weight_usedinternally. -
**Spool
-
Inventory — Unified 3MF-Based Usage Tracking** (#336) — All spools (Bambu Lab and third-party) now use 3MF slicer estimates as the primary tracking source. Per-filament
used_gdata from the archived 3MF file provides precise per-spool consumption. For failed or aborted prints, per-layer G-code analysis provides accurate partial usage up to the exact failure layer, with linear progress scaling as fallback. AMS remain% delta is the final fallback for G-code-only prints without an archived 3MF. Slot-to-tray mapping uses queueams_mappingfor queue-initiated prints and the printer'stray_nowstate for single-filament non-queue prints, ensuring the correct physical spool is always tracked. -
Notification Templates — Filament Usage Variables (#336) —
print_complete,print_failed, andprint_stoppednotification events now expose{filament_grams}(total grams, scaled by progress for partial prints),{filament_details}(per-filament breakdown with AMS slot info, e.g. "AMS-A T1 PLA: 12.4g | AMS-A T3 PETG: 2.8g"), and{progress}(completion percentage for failed/stopped prints). The{filament_details}variable includes the AMS unit and tray position for each filament used, with "Ext" shown for external spool holders. Falls back to type-only format (e.g. "PLA: 10.0g") when usage tracking data is unavailable. Webhook payloads includefilament_used,filament_details, andprogressfields. Per-slot filament data is stored in archiveextra_datafor downstream use. -
Printer Status Summary Bar — Next Available & Availability Count (#354) — The status bar on the Printers page now shows an availability count ("X available") alongside the printing/offline counts, and a "Next available" indicator showing which printing printer will finish soonest — with printer name, mini progress bar, completion percentage, and remaining time. Useful for print farms to quickly identify the next free printer. Updates in real-time via WebSocket. Translated in all 4 locales (en, de, ja, it).
-
Nozzle-Aware AMS Filament Mapping for Dual-Nozzle Printers (#318) — On dual-nozzle printers (H2D, H2D Pro), each AMS unit is physically connected to either the left or right nozzle. Bambuddy now reads nozzle assignments from the 3MF file (
filament_nozzle_map+physical_extruder_mapinproject_settings.config) and constrains filament matching to only AMS trays connected to the correct nozzle viaams_extruder_map. Applies to the print scheduler, reprint modal, queue modal, and multi-printer selection. Falls back gracefully to unfiltered matching when no trays exist on the target nozzle. The filament mapping UI shows L/R nozzle badges for dual-nozzle prints. Translated in all 4 locales (en, de, ja, it). -
Dual External Spool Support for H2D — H2-series printers with two external spool holders (Ext-L and Ext-R) are now fully supported. The external spool section renders as a grid with both slots, each showing filament type, color, fill level, and hover card details. Previously only a single external spool was displayed. Applies to the printer card, filament mapping, print scheduler, usage tracking, and inventory assignment. The
vt_trayfield is now an array across the entire stack (MQTT, API, WebSocket, frontend). -
AMS Slot Configuration — Model Filtering & Pre-Population — The Configure AMS Slot modal now filters filament presets by the connected printer model. Only presets matching the printer (e.g., "@BBL X1C" presets for X1C printers) and generic presets without a model suffix are shown. Local presets are filtered by their
compatible_printersfield. When re-configuring an already-configured slot, the modal pre-selects the saved preset, pre-populates the color, and auto-selects the active K-profile. The preset list auto-scrolls to the selected item. All modal strings are now fully translated in 5 locales (en, de, fr, it, ja). -
K-Profiles View — Accurate Filament Name Resolution — K-profile filament names are now resolved from builtin filament tables and user cloud presets (via new
/cloud/filament-id-mapendpoint) instead of showing raw IDs like "GFU99" or "P4d64437". Falls back to extracting names from the profile name field. -
Print Log — New view mode on the Archives page showing a chronological table of all print activity. Columns include date/time, print name, printer, user, status, duration, and filament. Supports filtering by search text, printer, user, status, and date range. Pagination with configurable page size. A dedicated clear button deletes only log entries without affecting archives. Data is stored in a separate
print_log_entriesdatabase table. -
Sync Spool Weights from AMS — New button in Settings → Filament Tracking (built-in inventory mode) to force-sync all inventory spool weights from the live AMS remain% values of connected printers. Overwrites the database weight data with current sensor readings. Useful for recovering from corrupted weight data (e.g., after a power-off event zeroed all fill levels). Requires printers to be online. Includes a confirmation modal.
-
Notification Thumbnails for Telegram & ntfy (#372) — Print thumbnail images are now attached to Telegram and ntfy notifications (previously only Pushover and Discord). Telegram uses the
sendPhotoAPI with the image as caption attachment. ntfy sends the image as a binary PUT withFilenameandMessageheaders. No configuration needed — images are sent automatically when available. -
Clear HMS Errors — New "Clear Errors" button in the HMS error modal sends a
clean_print_errorMQTT command to dismiss staleprint_errorvalues that persist after print cancellation or transient events. Locally clears the error list for immediate UI feedback. Permission-gated toprinters:control. The button only appears when there are active errors.
Fixed
- Firmware Upload Uses Wrong Filename on Cache Hit — The firmware update uploader cached downloaded firmware files under a mangled name (e.g.,
X1C_01_09_00_10.bin) instead of the original filename from Bambu Lab's CDN. On the first download the correct filename was uploaded to the SD card, but on subsequent attempts the cached file with the wrong name was used — causing the printer to not recognize the firmware file. Now caches using the original filename so the SD card always receives the correct file. - Update Check Runs When Disabled (#367) — The Settings page triggered an update check on every visit even when "Check for updates" was disabled, causing error popups on air-gapped systems with no internet. The backend
/updates/checkendpoint also ignored the setting entirely. Now the backend returns early without making GitHub API calls when the setting is disabled, the Settings page respects thecheck_updatesflag before auto-fetching, and the printer card firmware badge shows a neutral version-only display instead of disappearing when firmware update checks are off. - Stale Inventory Assignments Persist After Switching to Spoolman Mode — When switching from built-in inventory to Spoolman mode, existing spool-to-AMS-slot assignments were not cleaned up. The printer card hover cards continued showing "Assign Spool" buttons that opened the internal inventory modal, and any prior assignments remained visible. Now bulk-deletes all
SpoolAssignmentrecords when enabling Spoolman, invalidates the frontend cache so printer cards update immediately, and hides the inventory assign/unassign UI on printer cards while in Spoolman mode. - Bulk Archive Delete Leaves Orphaned Database Records — When bulk-deleting archives, the files were removed from disk before the database commit. If concurrent SQLite writes caused a lock timeout, the commit failed and rolled back — leaving database records pointing to deleted files (broken thumbnails, 404 errors). Fixed by deleting the database record first and only removing files after a successful commit.
- Model-Specific Maintenance Tasks for Carbon Rods vs Linear Rails (#351) — Maintenance tasks "Clean Carbon Rods" and "Lubricate Linear Rails" were shown for all printers regardless of motion system. H2 and A1 series use linear rails (not carbon rods), and X1/P1/P2S series use carbon rods (not linear rails). Maintenance types are now classified by rod/rail type: "Lubricate Carbon Rods" and "Clean Carbon Rods" for X1/P1/P2S, "Lubricate Linear Rails" and "Clean Linear Rails" for A1/H2. Stale and duplicate system types are automatically cleaned up on startup. Includes model-specific wiki links and i18n keys for all 4 locales.
- AMS Slot Configuration Overwritten on Startup — Bambuddy was resetting AMS slot filament presets on every startup and reconnection. The
on_ams_changecallback unconditionally unlinked Bambu Lab spool assignments on each MQTT push-all response, then re-assigned them by sendingams_filament_settingwithout asetting_id, which cleared the printer's filament preset. Now compares spool RFID identifiers (tray_uuid/tag_uid) before unlinking — if the same spool is still in the slot, the assignment is preserved and noams_filament_settingcommand is sent. - Bambu Lab Spool Detection False Positives — The
is_bambu_lab_spool()function (backend) andisBambuLabSpool()(frontend) incorrectly identified third-party spools as Bambu Lab spools when they used Bambu generic filament presets (e.g., "Generic PLA"). Thetray_info_idxfield (e.g., "GFA00") identifies the filament type, not the spool manufacturer — third-party spools using Bambu presets also have GF-prefixed values. Removedtray_info_idxfrom detection logic; now uses only hardware RFID identifiers (tray_uuidandtag_uid) which are physically embedded in genuine Bambu Lab spools. - FTP Disconnect Raises EOFError When Server Dies —
BambuFTPClient.disconnect()only caughtOSErrorandftplib.Error, butquit()raisesEOFErrorwhen the server has closed the connection mid-session.EOFErroris not a subclass of either, so it propagated to callers. Now caught alongside the other exception types for clean best-effort disconnect. - RFID Spool Data Erased by Periodic AMS Updates — Periodic MQTT push-all responses cleared
tag_uidandtray_uuidfields because they were included in the "always update" list. These fields are now preserved during updates and only cleared when a spool is physically removed (slot clearing detected by emptytray_type). This fixes the AMS "eye" icon disappearing for RFID spools after startup. - AMS Slot Configuration Overwrites RFID Spool State — Configuring an AMS slot for an RFID-detected Bambu Lab spool sent
ams_set_filament_setting, which replaced the firmware's RFID-managed filament config with a manual one — causing the slicer's "eye" icon to change to a "pen" icon. Now detects RFID spools and skips the filament setting command, only sending K-profile selection. - K-Profile Selection Corrupts Existing Profiles on X1C/P1S — The
extrusion_cali_selcommand included asetting_idfield that BambuStudio never sends, causing firmware to mislink calibration data. Theextrusion_cali_setcommand was sent unconditionally, overwriting existing profile metadata. Nowsetting_idis removed from selection commands, andextrusion_cali_setis only sent when no existing profile is selected (cali_idx < 0). - AMS Slot Configure — Black Filament Color Not Pre-Populated — When re-opening the Configure AMS Slot modal for a slot with black filament, the color field was empty despite the preset and K-profile being correctly pre-selected. The color pre-population logic excluded hex
000000(black) as a guard against empty slots, but empty slots already skip color data entirely. Removed the unnecessary check so black is now pre-populated like any other color. - Archive List View Not Labeling Failed Prints (#365) — The archive grid view displayed a red "Failed" / "Cancelled" badge on failed and aborted prints, but the list view had no equivalent indicator. Now shows an inline status badge next to the print name in list view.
- Reprint Fails with SD Card Error for Archives Without 3MF File (#376) — When a print was sent from an external slicer and Bambuddy couldn't download the 3MF from the printer during auto-archiving, the fallback archive had no file. Attempting to reprint such an archive tried to upload the data directory as a file, causing a confusing "SD card error." The backend now returns a clear error for file-less archives, and the frontend disables Print/Schedule/Open in Slicer buttons with a tooltip explaining that the 3MF file is unavailable.
- Inventory Spool Weight Resets After Print Completes — After a print, the usage tracker correctly updated
weight_used(e.g., +1.6g), but periodic AMS status updates recalculatedweight_usedfrom the AMS remain% sensor and overwrote the precise value. For small prints on large spools (e.g., 1.6g on 1000g), the AMS remain% stays at 100% (integer resolution = 10g steps), resettingweight_usedback to 0. The AMS weight sync now only increasesweight_used, never decreases it, preserving precise values from the usage tracker. - All Spool Fill Levels Drop to Zero When Printers Power Off — When a printer powers off, the AMS sensor can report
remain=0for all trays whiletray_typeis still populated. The weight sync treated 0% remain as "100% consumed," computingweight_used = label_weight(e.g., 1000g). The "only increase" guard passed becauselabel_weight > current_used + 1, marking every assigned spool as fully consumed. The AMS weight sync now skipsremain=0entirely — a physically empty spool is tracked by the usage tracker during the print, not by a transient AMS sensor reading. - Spool Edit Form Overwrites Usage-Tracked Weight — Editing any spool field (note, color, material, etc.) sent the full form data back to the server, including
weight_used. If the frontend cache was stale (e.g., loaded before the last print completed), saving the form would silently resetweight_usedto the pre-print value, reverting the remaining weight to full. The form now only includesweight_usedin the update request when the user explicitly changes the weight field. - K-Profile Auto-Select Fails for Non-BL Spools on Dual-Nozzle Printers — When assigning a third-party spool to an AMS slot on dual-nozzle printers (H2D, H2D Pro), the MQTT auto-configure step crashed with
'SpoolKProfile' object has no attribute 'extruder_id'. The K-profile model usesextruder(notextruder_id). Fixed the attribute name so K-profile matching correctly filters by nozzle on dual-extruder printers. - Loose Archive Name Matching Could Cause Wrong Archive Reuse (#374) — The
on_print_startcallback usedilike('%{name}%')to find existing "printing" archives, which meant a print named "Clip" could incorrectly match "Cable Clip" or "Clip Stand". This could cause a new print to reuse the wrong archive or skip creating one. Tightened to exactprint_namematch or exact filename variants (.3mf,.gcode.3mf). - Phantom Prints on Power Cycle (#374) — The print queue uploaded
.3mffiles to the printer's SD card root (/) but never deleted them after the print finished. Some printers (e.g. P1S) auto-start files found in the root directory on power cycle, causing ghost prints on every reboot. Now deletes the uploaded file from the SD card after print completion (best-effort, non-blocking). The cleanup also tries.gcodefiles and retries up to 3 times with a 2-second delay to handle printers that briefly lock the filesystem after a print ends. Runs before the archive lookup so it works even when auto-archiving is disabled. - Queue Items Stuck in "Printing" After Print Completes — The queue item status update (from
printingtocompleted/failed) was placed after an early return that exits when the archive record cannot be found. If the archive lookup failed (e.g. app restart mid-print, manual archive deletion), the function returned early and the queue item stayed inprintingforever. Over multiple print cycles, stale items accumulated — causing the "Printing" count to show double the actual printers and completed prints to remain in the "Currently Printing" section. Moved the queue item status update (including MQTT relay notification, queue-completed notification, and auto-power-off) to before the archive lookup early return so it always runs. - Spool Form Scrollbar Flicker in Edge (#364) — The Add/Edit Spool modal's scrollable area used
overflow-y: auto, which on Windows Edge (where scrollbars take layout space) caused the scrollbar to appear and disappear on hover — making the color picker unusable at certain zoom levels. Addedscrollbar-gutter: stableto reserve scrollbar space and prevent layout thrashing. - Archive Duplicate Badge Misses Name-Based Duplicates (#315) — The duplicate badge on archive cards only matched by file content hash, so re-sliced prints of the same model (different GCODE, same print name) were not flagged as duplicates. Now also matches by print name (case-insensitive), consistent with the detail view's duplicate detection.
- Schedule Print Allows No Plate Selected for Multi-Plate Files (#394) — When scheduling a multi-plate file from the file manager, the modal showed a "Selection required" warning but still allowed submission without selecting a plate. The job defaulted to plate 1, but the queue item didn't indicate which plate, and editing showed no plate selected. Now auto-selects the first plate by default when plates load, and the submit button validation applies to both archive and library files.
- 3MF Usage Tracking Broken for Queue Prints from File Manager (#364) — When a print was queued from the file manager (library file), the scheduler did not create an archive or register the expected print. The
on_print_startcallback had to re-download the 3MF from the printer via FTP, and if that failed, a fallback archive was created without the 3MF file — making 3MF-based filament usage tracking impossible. The queue item'sarchive_idalso remained NULL, so the usage tracker could not find the queue's AMS slot mapping for correct spool resolution. The scheduler now creates an archive from the library file before uploading, links it to the queue item, and registers it as an expected print — matching the behavior of the direct library print route. - Printer Queue Widget Shows "Archive #null" for File Manager Prints (#364) — The "Next in queue" widget on the printer card only checked
archive_nameandarchive_idwhen displaying the queued item name. Queue items from the file manager havelibrary_file_nameandlibrary_file_idinstead, so the widget displayed "Archive #null". Now falls back tolibrary_file_nameandlibrary_file_id, matching the Queue page display logic. - Inventory Usage Not Tracked for Remapped AMS Slots (#364) — When reprinting an archive with a different AMS slot mapping (e.g. changing from slot A1 to C4 in the mapping modal), the usage tracker used the default 3MF slot-to-tray mapping instead of the actual mapping from the print command. The
ams_mappingfrom reprint, library print, and queue print commands is now stored and used as the highest-priority mapping source for usage tracking. - Inventory Usage Not Tracked for Slicer-Initiated Prints on H2D (#364) — On H2D printers, the AMS
tray_nowfield is always 255 in MQTT data. The actual tray is resolved via the snow field ~44 seconds after print start, but reverts to "unloaded" when the AMS retracts filament at completion. The usage tracker now trackslast_loaded_tray— the last valid tray seen during printing — as a fallback when bothtray_nowat start and at completion are invalid. Also capturestray_nowat print start for printers that report a valid value before the RUNNING state. - Inventory Usage Wrong Tray for Slicer-Initiated Prints (#364) — When a print was started from an external slicer (BambuStudio, OrcaSlicer, Bambu Handy), Bambuddy never saw the
ams_mappingthe slicer sent, because it only subscribed to the printer's report topic. The usage tracker fell back totray_nowwhich could resolve to the wrong AMS tray (e.g., Black PLA at A2 instead of Green PLA at A4 on H2D Pro). Now subscribes to the MQTT request topic to intercept print commands from any source, capturing theams_mappinguniversally — regardless of who starts the print. The request topic subscription is fail-safe: if the printer's MQTT broker rejects it (e.g., P1S), Bambuddy detects the rejection via SUBACK or disconnect timing and gracefully disables the subscription for that printer, falling back to the existingtray_now-based tracking without breaking the MQTT connection. - P1S Timelapse Not Detected — AVI Format Support (#405) — P1-series printers save timelapse videos as
.avi(MJPEG), but the timelapse scanner only looked for.mp4files — so P1S timelapses were never found or attached to archives. Now discovers both.mp4and.avitimelapse files across all FTP directories (/timelapse,/timelapse/video,/record,/recording). AVI files are saved immediately and converted to MP4 in a non-blocking background task using FFmpeg with-threads 1andnice -n 19to minimize CPU impact on Raspberry Pi. If FFmpeg is unavailable, the AVI is served as-is with the correct MIME type. The manual "Scan for Timelapse" route also searches the additional directories used by P1-series printers. - Timelapse Upload & Remove (#406) — When the auto-scan attaches the wrong timelapse (e.g., from a different print), there was no way to remove it or attach the correct one. Added "Upload Timelapse" and "Remove Timelapse" context menu items. Upload accepts
.mp4,.avi, and.mkvfiles (non-MP4 auto-converted in background). Remove deletes the file and clears the database reference. Both actions are permission-gated and available in grid and list views. - Spool Assignments Falsely Unlinked After Print Due to Color Variation — The auto-unlink logic compared AMS tray colors against saved fingerprints using exact hex match. RFID sensors report slightly different color values across reads (e.g.
7CC4D5FFvs56B7E6FFfor the same spool, Euclidean distance ~43.6). Now uses a color similarity function with a tolerance threshold of 50, preventing false unlinks from minor RFID/firmware color variations while still detecting genuinely different spools.
Improved
- Virtual Printer: Dual Bind/Detect Ports 3000 + 3002 (#445) — BambuStudio/OrcaSlicer require a bind/detect handshake before connecting via MQTT/FTP. Different slicer versions use port 3000 or 3002, so the BindServer and proxy now listen on both ports for full compatibility. Docker users in bridge mode need to expose both (
-p 3000:3000 -p 3002:3002). - Usage Tracking Diagnostic Logging (#364) — Added INFO-level logging at print start and completion that dumps the printer's MQTT
mappingfield,tray_now,last_loaded_tray, all mapping-related raw data keys, and per-AMS-tray summaries (type, color, tray_now, tray_tar). Enables investigating the slot-to-tray mapping behavior across different printer models (X1E, H2D Pro, P1S, etc.) without requiring DEBUG mode. - Skip Objects: Click-to-Enlarge Lightbox (#396) — The skip objects modal's small 208px image panel made it difficult to distinguish object markers when parts are small or close together. Clicking the image now opens a fullscreen lightbox overlay with the same image and markers at a much larger size (up to 600px). The 24px marker circles are proportionally smaller relative to the enlarged image, solving the overlap problem. Close via X button, Escape key, or clicking the backdrop. Escape cascades correctly — closes lightbox first, then the modal.
- Phantom Print Investigation — Logging & Hardening (#374) — Added targeted logging and hardening to help diagnose reports of prints starting automatically without user input. Debug log volume reduced ~90% by suppressing
sqlalchemy.engine(changed from INFO to WARNING) andaiosqlite(new WARNING suppression) noise that previously filled 2.5MB in 16 minutes. Everystart_print()call now logs aPRINT COMMANDtrace with the caller's file, line, and function name. The print scheduler logs pending queue items when found.on_print_completewarns when multiple queue items are in "printing" status for the same printer, which signals a state inconsistency. - Reduce Log Noise from MQTT Diagnostics (#365) — Downgraded 58 high-frequency MQTT diagnostic messages from INFO to DEBUG level. Payload dumps, detector state changes, field discovery logs, H2D disambiguation, and periodic status updates no longer flood the log at the default INFO level. Also suppresses paho-mqtt library INFO messages in production. User-initiated actions (print start/stop, AMS load/unload, calibration) remain at INFO. All diagnostic detail is still available when debug logging is enabled.
- SQLite WAL Mode for Database Reliability — Database now uses Write-Ahead Logging (WAL) mode with a 5-second busy timeout, reducing "database is locked" errors under concurrent access. WAL mode allows simultaneous reads during writes, improving responsiveness for multi-printer setups. Automatically enabled on startup.
- External Camera Not Used for Snapshot + Stream Dropping (#325) — The snapshot endpoint (
/camera/snapshot) always used the internal printer camera even when an external camera was configured. Now checks for external camera first, matching the existing stream endpoint behavior. Also fixed external MJPEG and RTSP streams silently dropping every ~60 seconds due to missing reconnect logic — the underlying stream generators exit on read timeout, and the caller now retries up to 3 times with a 2-second delay instead of ending the stream. - H2C Nozzle Rack Text Unreadable on Light Filament Colors (#300) — Nozzle rack slots use the loaded filament color as background, but white/light filaments made the white "0.4" text nearly invisible. Now uses a luminance check to switch to dark text on light backgrounds.
- File Downloads Show Generic Filenames (#334) — Downloaded files with special characters in their names (spaces, umlauts, parentheses) were saved as generic
file_1,file_2instead of the original filename. TheContent-Dispositionheader parser now handles RFC 5987 percent-encoded filenames (filename*=utf-8''...) used by FastAPI for non-ASCII characters. Fix applied to all download endpoints (library files, archives, source files, F3D files, project exports, support bundles, printer files). - Printer Card Cover Image Not Updating Between Prints — The cover image on the printer card only refreshed on page reload. The
<img>URL was always the same (/printers/{id}/cover) regardless of which print was active, so the browser served its cached image. Now appends the print name as a cache-busting query parameter so the browser fetches the new cover when a different print starts. - Telegram Bold Title Broken by Underscores in Message (#332) — Telegram notifications showed literal
*Title*asterisks instead of bold text when the message body contained underscores (e.g. job nameA1_plate_8, error code0300_0001). The code was disabling Markdown parsing entirely when underscores were detected. Now escapes underscores in the body with\_so Markdown rendering stays enabled. - Queued Jobs Incorrectly Archived After Duplicate Execution Detection (#341) — When the same file was added to the print queue multiple times, only the first job executed. All subsequent jobs were automatically skipped with "already printed X hours ago" because they shared the same archive reference, and a safety check incorrectly treated them as phantom reprints. The same issue also affected single queue items created from recently completed archives. Removed the overly broad 4-hour duplicate detection check — the crash recovery scenario it guarded against is already handled by the queue item status lifecycle.
New Features
- External Links: Open in New Tab (#338) — External sidebar links can now optionally open in a new browser tab instead of an iframe. Sites behind reverse proxies (Traefik, nginx) that send
X-Frame-Options: SAMEORIGINor CSPframe-ancestorsheaders block iframe embedding, causing "refused to connect" errors. A new "Open in new tab" toggle in the add/edit link modal lets users choose per-link. Keyboard shortcuts (number keys) also respect the setting. Defaults to iframe (existing behavior) for backward compatibility. - Print Queue: Clear Plate Confirmation — When a print finishes or fails and more items are queued, the printer card now shows a "Clear Plate & Start Next" button. The scheduler no longer auto-starts the next print while the printer is in FINISH or FAILED state — the user must confirm the build plate has been cleared first. This prevents prints from starting on a dirty plate. The button respects the
printers:controlpermission and is available in all supported languages (en/de/ja). - Clear Plate State Persists Across Page Refresh (#410) — After clicking "Clear Plate & Start Next", refreshing the page showed the Clear Plate button again because the frontend determined the state purely from the printer's FINISH/FAILED status. The
plate_clearedflag is now included in the printer status API response, so the widget correctly shows the passive queue link instead of the Clear Plate button after acknowledgment — even after a page refresh.
Improved
- Skip Objects: Confirmation Dialog (#346) — Added a warning confirmation modal before skipping an object during a print. Shows the object name and warns the action is irreversible. Prevents accidentally skipping the wrong object. Translated in all 4 locales (en, de, ja, it).
- Additional Currency Options (#329, #333) — Added 17 additional currencies to the cost tracking dropdown: HKD, INR, KRW, SEK, NOK, DKK, PLN, BRL, TWD, SGD, NZD, MXN, CZK, THB, ZAR, RUB.
- Move Email Settings Under Authentication Tab — Renamed the settings "Users" tab to "Authentication" and moved the standalone "Global Email" tab into it as an "Email Authentication" sub-tab. Groups email/SMTP configuration with user management where it logically belongs. Legacy
?tab=emailURLs are handled automatically. - Inventory — Confirmation Modals for Delete & Archive — The inventory page now uses the app's styled confirmation modal for both delete and archive actions. Previously, delete used the browser's native
confirm()dialog and archive had no confirmation at all. Delete shows a danger-styled modal, archive shows a warning-styled modal. Translated in all 5 locales (en, de, fr, it, ja). - Default Color Catalog Expanded to 638 Colors Across 20 Brands — The built-in filament color catalog has been expanded from 258 entries (6 brands) to 638 entries (20 brands). Added Overture, Sunlu, Creality, Elegoo, Jayo, Inland, Eryone, ColorFabb, Fillamentum, FormFutura, Fiberlogy, MatterHackers, Protopasta, 3DXTECH, and Sakata3D. eSUN expanded from 10 generic placeholder entries to 79 measured colors across 10 material lines (PLA+, Pro PLA+, PLA, PLA Silk, PLA Metal, PLA-ST, PETG, PETG-HS, ABS, ABS+). All hex codes sourced from FilamentColors.xyz measured swatches.
- Settings — Built-in Inventory Feature Note — Added a note in Settings > Filament > Built-in Inventory that third-party spools can be assigned to inventory spools for tracking.
- Catalog Settings Cards Taller — Spool Catalog and Color Catalog settings panels increased from 400px to 600px max height for better browsability with the expanded default catalogs.
[0.1.9] - 2026-02-10
New Features
- Advanced Authentication via Email (#322) — Optional SMTP-based email integration for streamlined user onboarding and self-service password management. Admins configure SMTP settings and create users with just a username and email — the system generates a secure random password and emails it directly to the new user. Admins can trigger one-click password resets from User Management. Users can reset their own forgotten password from the login screen without contacting an admin. Includes customizable email templates for welcome emails and password resets. Username and email login is case-insensitive. Can be enabled or disabled independently at any time without affecting existing accounts.
- Configurable Slicer Preference (#313) — New "Preferred Slicer" setting in General settings to choose between Bambu Studio and OrcaSlicer. Controls the protocol used by all "Open in Slicer" buttons across Archives, 3D Preview, and context menus. OrcaSlicer uses the
orcaslicer://open?file=protocol. Default remains Bambu Studio for backward compatibility. - Local Profiles — OrcaSlicer Import (#310) — Import slicer presets from OrcaSlicer without Bambu Cloud. Supports
.orca_filament,.bbscfg,.bbsflmt,.zip, and.jsonexports. Resolves OrcaSlicer inheritance chains by fetching base Bambu profiles from GitHub (cached locally with 7-day TTL). Stores presets in the database with extracted core fields (material type, vendor, nozzle temps, pressure advance, compatible printers). New "Local Profiles" tab on the Profiles page with drag-and-drop import, 3-column layout (Filament/Process/Printer), search, and expandable preset details. Local filament presets appear in AMS slot configuration alongside cloud presets. Includes smart profile type detection (explicit type field, ZIP path hints, settings ID keys, content heuristics, and name-based patterns) and material/vendor extraction from preset names as fallback. - Hostname Support for Printers (#290) — Printers can now be added using hostnames (e.g.,
printer.local,my-printer.home.lan) in addition to IPv4 addresses. Updated backend validation, frontend forms, and all locale labels. - Camera View Controls (#291) — Added chamber light toggle and skip objects buttons to both embedded camera viewer and standalone camera page. Extracted skip objects modal into a reusable
SkipObjectsModalcomponent shared across PrintersPage and both camera views. - Per-Filament Spoolman Usage Tracking (#277) — Accurate per-filament usage tracking for Spoolman integration with G-code parsing. Parses 3MF files at print start to build per-layer, per-filament extrusion maps. Reports accurate partial usage when prints fail or are cancelled based on actual layer progress. Tracking data stored in database to survive server restarts. Uses Spoolman's filament density for mm-to-grams conversion. Prefers
tray_uuidovertag_uidfor spool identification. - Disable AMS Weight Sync Setting (#277) — New toggle to prevent AMS percentage-based weight estimates from overwriting Spoolman's granular usage-based calculations. Includes conditional "Report Partial Usage for Failed Prints" toggle.
- Home Assistant Environment Variables (#283) — Configure Home Assistant integration via
HA_URLandHA_TOKENenvironment variables for zero-configuration add-on deployments. Auto-enables when both variables are set. UI fields become read-only with lock icons when env-managed. Database values preserved as fallback. - Spoolman Fill Level for AMS Lite / External Spools (#293) — AMS Lite (no weight sensor) always reported 0% fill level. Now uses Spoolman's remaining weight as a fallback when AMS reports 0%. External spools also show fill level from Spoolman data. Fill bars and hover cards indicate "(Spoolman)" when the data source is Spoolman rather than AMS.
- Extended Support Bundle Diagnostics — Support bundle now collects comprehensive diagnostic data for faster issue resolution: printer connectivity and firmware versions, integration status (Spoolman, MQTT, Home Assistant), network interfaces (subnets only), Python package versions, database health checks, Docker environment details, WebSocket connections, and log file info. All data properly anonymized — no IPs, names, or serials included. Privacy disclosure updated on System Info page.
Improved
- H2C Nozzle Rack — 6-Slot Display With Empty Placeholders (#300) — The nozzle rack card now always shows 6 rack positions (IDs 16–21), with filled slots showing diameter and empty slots showing placeholder dashes. L/R hotend nozzles (IDs 0, 1) are excluded from the rack card and shown in the dedicated L/R indicator instead.
- H2 Series — L/R Nozzle Hover Card (#300) — New dual-nozzle hover card shows L and R nozzle details side by side (diameter, type, flow, status, wear, max temp, serial). Active nozzle highlighted in amber with Active/Idle status based on
active_extruder, replacing the misleading "Docked" label. - H2 Series — Single-Nozzle Hover Card (#300) — H2D/H2S printers with a single nozzle now show extended nozzle details (wear, serial, max temp) on hover over the temperature card. Backend changed from H2C-only (>2 nozzles) to all H2 series (any nozzle_info present).
- H2C Nozzle Rack — Translate Type Codes & Add Flow Info (#300) — Raw nozzle type codes (e.g. "HS", "HH01") are now translated to human-readable names: material (Hardened Steel, Stainless Steel, Tungsten Carbide) and flow type (High Flow, Standard). New "Flow" row in the hover card. Translations added in all 4 locales (en, de, ja, it).
- H2C Nozzle Rack — Show Filament Material in Hover Card (#300) — Nozzle hover card now shows the loaded filament material type (e.g. "PLA", "PETG") alongside the color swatch, captured from MQTT nozzle info data.
- H2C Nozzle Rack — Resolve Filament Names From Cloud & Local Profiles (#300) — Nozzle rack hover card previously showed raw filament IDs like "GFU99" instead of human-readable names. Now resolves filament names with a 4-tier fallback: Bambu Cloud preset lookup → local slicer profiles → built-in filament name table (86 known Bambu filament codes) → raw ID fallback. The built-in table resolves names like "Bambu ASA", "Generic TPU", "Generic PLA" when the cloud API returns 400 for certain filament IDs. Also benefits AMS tray tooltips.
- H2C Nozzle Rack Compact Layout (#300) — Redesigned nozzle rack from a 2×3 grid to a compact single-row layout with bottom accent bars (green = mounted, gray = docked). Temperature cards are thinner, rack card is wider (flex-[2]), and all cards vertically centered.
- Firmware Version Badge on Printer Card (#311) — Printer cards now show a firmware version badge (when firmware checking is enabled). Green with checkmark when up to date, orange with download icon when an update is available. Clicking the badge opens a firmware info modal showing release notes (auto-expanded when up to date) or the existing update workflow. Badge and modal respect
firmware:readandfirmware:updatepermissions. Translations added in all 4 locales. - Auto-Detect Subnet for Printer Discovery — Docker users no longer need to manually enter a subnet in the Add Printer dialog. Bambuddy auto-detects available network subnets and pre-selects the first one. When multiple subnets are available (e.g., eth0 + wlan0), a dropdown lets users choose. Falls back to manual text input if no subnets are detected.
- Japanese Locale Complete Overhaul — Restructured
ja.tsfrom a divergent format (different key structure, 12 structural conflicts, 1,366 missing translations) to match the English/German locale structure exactly. Translated all 2,083 keys into Japanese, achieving full parity with EN/DE. Zero structural divergences, zero missing keys.
Fixed
- Nozzle Rack Hides 0% Wear (#300) — New nozzles with 0% wear showed no wear info in the hover card because the condition treated 0 the same as "not available." Now displays "Wear: 0%" correctly. The field is still hidden when the printer doesn't report wear data.
- Nozzle Rack Shows L/R Hotend Nozzles in Rack (#300) — The nozzle rack card incorrectly included L/R hotend nozzles (IDs 0, 1) alongside the 6 rack slots. Now filters to IDs >= 2 (rack only) and always pads to 6 positions with empty placeholders.
- H2C Firmware Update Downloads Wrong Firmware (#311) — H2C printers were mapped to the H2D firmware API key (
h2d), causing firmware checks to offer H2D firmware instead of H2C firmware. H2C has its own firmware track (01.01.x.x vs H2D's 01.02.x.x). Added separateh2cAPI key mapping. Also added missing H2C/H2S entries to printer model ID and 3MF model maps. - Sidebar Links Custom Icons Have Inverted Colors (#308) — Custom uploaded icons in sidebar links had their colors inverted in dark mode due to a CSS
invert()filter. The filter was intended for monochrome preset icons but was incorrectly applied to user-uploaded images (e.g., full-color logos). Removed the invert filter from custom icon rendering in the sidebar and the add/edit link modal. - Virtual Printer FTP Transfer Fails With Connection Reset (#58) — Large 3MF uploads to the virtual printer intermittently failed with
[Errno 104] Connection reset by peerwhile the small verify_job always succeeded. The_handle_data_connectioncallback returned immediately, allowing the asyncio server-handler task to complete while the data connection was still in active use. The passive port listener also stayed open during transfers, risking duplicate data connections. Fixed by keeping the callback alive until the transfer completes (_transfer_doneevent), closing the passive listener after accepting the connection, and rejecting duplicate data connections. Also added a 5-second drain timeout to MQTT status pushes to prevent blocking when the slicer is busy uploading. - Virtual Printer IP Override for Server Mode (#52) — The
remote_interface_ipsetting (network interface override) was only used in proxy mode, but users with multiple network interfaces (LAN + Tailscale, Docker bridges) also needed it in server modes (immediate/review/print_queue). Auto-detected IP from_get_local_ip()followed the OS default route, causing wrong IP in TLS certificate SAN (handshake failures) and SSDP broadcasts (slicer can't discover printer). Now the interface override applies to all modes: included in certificate SAN, passed to SSDP server as advertise IP, and triggers service restart on change. UI dropdown shown for all modes when enabled (not just proxy). - Wrong Thumbnail When Reprinting Same Project (#314) — Reprinting a project with the same name but a different bed layout showed the old thumbnail during printing. The cover image cache was keyed by
subtask_nameand never invalidated between prints, so a cache hit returned the stale first-print thumbnail. Now the cover cache is cleared on every print start. - Wrong Timelapse Attached to Archive (#315) — After a print, the archive could receive a timelapse from a previous print instead of the just-completed one. The auto-scan sorted MP4 files by mtime and grabbed the "most recent," but in LAN-only mode (no NTP) the printer's clock is wrong, making mtime unreliable. Replaced with a snapshot-diff approach: baseline existing files before waiting, then detect the new file that appears after encoding. Falls back to print-name matching if no new file is found after retries.
- Timelapse Not Attached — Baseline Race Condition (#315) — Follow-up to the snapshot-diff timelapse fix: the baseline of existing MP4 files was captured at print completion time inside a background task, but fast-encoding printers could finish writing the timelapse before the baseline was taken, causing the new file to appear in the baseline and never be detected as "new." Moved baseline capture to print start time, when the timelapse file cannot possibly exist yet. Falls back to completion-time baseline if the app was restarted mid-print.
- Calibration Prints Archived (#315) — Standalone calibration prints (flow, vibration, bed leveling) were being archived as regular prints. The calibration gcode (
/usr/etc/print/auto_cali_for_user.gcode) and other internal printer files under/usr/are now detected and skipped during print start. - Camera Stop 401 When Auth Enabled — Camera stop requests (
sendBeacon) failed with 401 Unauthorized when authentication was enabled becausesendBeaconcannot send auth headers. Replaced withfetch+keepalive: truewhich supports Authorization headers while remaining reliable during page unload. - Spoolman Creates Duplicate Spools on Startup (#295) — Each AMS tray independently fetched all spools from Spoolman, causing redundant API calls and duplicate spool creation with large databases (300+ spools). Now fetches spools once and reuses cached data across all tray operations. Added retry logic (3 attempts, 500ms delay) with connection recreation for transient network errors.
- Filament Usage Charts Inflated by Quantity Multiplier (#229) — Daily, weekly, and filament-type charts were multiplying
filament_used_gramsby print quantity, even though the value already represents the total for the entire job. A 26-object print using 126g was counted as 3,276g. Removed the erroneous multiplier from three aggregations inFilamentTrends.tsx. - Energy Cost Shows 0.00 in "Total Consumption" Mode (#284) — Statistics Quick Stats showed 0.00 energy cost when Energy Display Mode was set to "Total Consumption" with Home Assistant smart plugs. The
homeassistant_servicewas not configured with HA URL/token before querying plug energy data, causing it to silently return nothing. - H2D Pro Prints Fail at ~75% With Extrusion Motor Overload (#245) — H2D Pro firmware interprets
use_ams: 1(integer) as a nozzle index, routing filament to the deputy nozzle instead of the main nozzle. Bambu Studio sendsuse_ams: true(boolean) while using integers for other fields. Fixed by keepinguse_amsas boolean for all printers including H2D series. - GitHub Backup Description Misleading — The "App Settings" backup card said "excludes sensitive data" but the complete database is pushed. Updated description to "complete database."
- Support Bundle Shows 0 AMS Units — The support info always reported
ams_unit_count: 0because it expectedraw_data["ams"]to be a nested dict ({"ams": [...]}) but the MQTT handler stores it as a flat list. Now handles both formats. - Firmware Badge Shown for Models Without API Data (#311) — Printers whose model has no firmware data in Bambu Lab's API (e.g. H2C on public beta firmware) showed a misleading green "up to date" badge. The badge is now hidden when the API returns no
latest_version, since there is nothing to compare against. - AMS-HT Mapping Fails for Left Nozzle on H2D Pro (#318) — Printing with the left nozzle on dual-nozzle printers (H2D/H2D Pro) using AMS-HT failed with "Failed to get AMS mapping table." The global tray ID for AMS-HT units (ams_id >= 128) was calculated as
ams_id * 4 + tray_id(= 512), but AMS-HT uses the rawams_id(128) since it has a single tray. The backend then misidentified 512 as an external spool. Fixed in frontend tray ID calculation, backendams_mapping2builder, print scheduler, and Spoolman tracking. - H2D Pro L/R Nozzle Hover Card Swapped (#300) — The dual-nozzle hover card had left and right nozzles swapped: nozzle_rack id 0 (extruder 0 = right) was shown as left and vice versa. Serial number and max temp now correctly appear only on the right (removable) nozzle column.
- H2C Printer Card Shows H2D Image (#300) — The H2C printer card displayed the H2D printer image because no dedicated H2C image existed in the frontend. Added H2C image and updated
getPrinterImage()to return it for H2C models. - H2C Nozzle Rack Shows Wrong Empty Slot and Missing Filament Colors (#300) — Empty rack slots always appeared at position 6 instead of their actual position because nozzles were mapped by array index instead of by ID. Fixed by mapping each nozzle to its correct rack position (
id - 16). Filament colors and materials were missing because the H2C uses different MQTT field names (color_m,fila_id,sn,tm) than the H2D (filament_colour,filament_id,serial_number,max_temp). Added fallback field name resolution. Also fixed nozzle rack layout breaking on medium card size by allowing the temperature row to wrap.
Documentation
- Advanced Auth via Email — Updated README, website features page, and wiki authentication guide with SMTP setup, self-service password reset, admin password reset, email templates, and advanced auth overview.
- Supported Printers Updated — Updated README, website, and wiki to list all 12 supported Bambu Lab printer models: X1, X1C, X1E, P1P, P1S, P2S, A1, A1 Mini, H2D, H2D Pro, H2C, H2S. Removed outdated "Testers Needed" messaging and Tested/Needs Testing distinctions — all models are now uniformly listed as supported. Added H2C printer image to website. Added H2D Pro, H2C columns to wiki feature comparison tables and new P2 Series section.
- CONTRIBUTING.md: i18n & Authentication Guides — Added Internationalization (i18n) section with locale file conventions, code examples, and parity rules. Added Authentication & Permissions section covering the opt-in auth pattern, permission conventions, and default group structure.
- Proxy Mode Security Warning — Added FTP data channel security warning to wiki, README, and website. Bambu Studio does not encrypt the FTP data channel despite negotiating PROT P; MQTT and FTP control channels are fully TLS-encrypted. VPN (Tailscale/WireGuard) recommended for full data encryption.
- Docker Proxy Mode Ports — Documented FTP passive data ports 50000-50100 required for proxy mode in Docker bridge mode. Updated port mappings in wiki virtual-printer and docker guides.
- SSDP Discovery Limitations — Added table showing when SSDP discovery works (same LAN, dual-homed, Docker host mode) vs when manual IP entry is required (VPN, Docker bridge, port forwarding). Updated wiki, README, and website.
- Firewall Rules Updated — Added port 50000-50100/tcp to all UFW, firewalld, and iptables examples for proxy mode FTP passive data.
Testing
- Mock FTPS Server & Comprehensive FTP Test Suite — Added 67 automated test cases against a real implicit FTPS mock server, covering every known FTP failure mode from 0.1.8+:
- Mock server (
mock_ftp_server.py) implements implicit TLS, custom AVBL command, and per-command failure injection - Connection tests: auth, SSL modes (prot_p/prot_c), timeout, cache, disconnect edge cases
- Upload tests: chunked transfer via
transfercmd(), progress callbacks, 553/550/552 error handling - Download tests: bytes, to-file, 0-byte regression, large files, missing file cleanup
- Model-specific tests: X1C session reuse, A1/A1 Mini prot_c fallback, P1S, unknown model defaults
- Async wrapper tests: upload/download/list/delete with A1 fallback and multi-path download
- Failure injection tests: regressions for
error_permhierarchy,diagnose_storageCWD propagation, injection count decrement - Added
pyOpenSSLtorequirements-dev.txtfor Docker test image compatibility
- Mock server (
- Nozzle Rack Tests — Backend: 7 tests for MQTT nozzle_info parsing (H2C 8-entry, H2D 2-entry, H2S single, empty, sorting, field mapping, nozzle state updates). Frontend: 3 tests for rack card rendering (H2C shows 6 slots, empty placeholders, hidden when no rack IDs).
[0.1.8.1] - 2026-02-07
Fixed
- FTP Upload Broken on All Printer Models — Fixed critical bug where all FTP uploads failed with "550 Failed to change directory":
diagnose_storage()was running before every upload, and its CWD failures (ftplib.error_perm) were not caught becauseerror_permis not a subclass oferror_reply- Removed
diagnose_storage()from the upload hot path - Changed all FTP exception handlers from
except (OSError, ftplib.error_reply)toexcept (OSError, ftplib.Error)to catch all FTP error types
- HTTP 500 on Reprint and Print Endpoints — Fixed 500 errors on
/api/v1/archives/{id}/reprintand/api/v1/library/files/{id}/printcaused by the FTP failure above - Exception Handling Reverted — Reverted overly-narrow exception handling introduced in 0.1.8 that could cause uncaught errors in archive parsing, HTTP clients, 3MF/ZIP processing, Home Assistant, and firmware checks
- HTTP 500 on Printer Cover Image — Fixed 500 error on
/api/v1/printers/{id}/coverwhen FTP download returned 0 bytes but reported success; now retries and falls back to 404 - 4-Segment Version Support — Version parser now supports patch releases like
0.1.8.1for hotfixes without incrementing the minor version
[0.1.8] - 2026-02-06
Security
- XML External Entity (XXE) Prevention:
- Replaced
xml.etree.ElementTreewithdefusedxmlacross all 3MF parsing code - Prevents XXE attacks through malicious 3MF files
- Detected by Bandit B314 security scanner
- Replaced
- Path Injection Vulnerabilities Fixed:
- Added path traversal validation to project attachment endpoints
- Strengthened filename sanitization in timelapse processing
- Prevents directory traversal attacks via
../sequences - Detected by CodeQL security scanner
- Security Scanning in CI/CD:
- Added Bandit (Python security analyzer) with SARIF upload to GitHub Security
- Added Trivy (container/IaC scanner) for Docker image and Dockerfile analysis
- Added pip-audit and npm-audit for dependency vulnerability scanning
- Automatic GitHub issue creation for detected vulnerabilities
- Security scan results visible in GitHub Security tab
- CodeQL Zero-Finding Baseline:
- Reduced CodeQL findings from 591 to 0 across Python, JavaScript, and GitHub Actions
- Created custom query suites (
.codeql/python-bambuddy.qls,.codeql/javascript-bambuddy.qls) with documented accepted-risk exclusions - All exclusions reviewed and justified (log injection parameterized, cyclic imports from SQLAlchemy ORM, intentional 0.0.0.0 binds, etc.)
- Log Injection Prevention:
- Converted ~700 f-string log calls to parameterized
%sstyle across all backend files - Prevents log injection via newlines or fake log entries in user-controlled data
- Converted ~700 f-string log calls to parameterized
- Exception Handling Hardened:
- Narrowed ~265 bare
except Exceptionblocks to specific types (OSError,KeyError,ValueError,zipfile.BadZipFile,sqlalchemy.exc.OperationalError, etc.)
- Narrowed ~265 bare
- Stack Trace Exposure Fixed:
- Replaced
str(e)with generic error messages in HTTP responses (updates.py) - Detailed errors still logged server-side for debugging
- Replaced
- SSRF Mitigations Added:
- Home Assistant integration: URL scheme/hostname validation, metadata-service blocking (
homeassistant.py) - Tasmota integration: IP validation blocking loopback and link-local addresses (
tasmota.py)
- Home Assistant integration: URL scheme/hostname validation, metadata-service blocking (
- Hashlib Security Annotations:
- Added
usedforsecurity=Falseto non-security hash calls (MD5 for AMS fingerprinting, SHA1 for git blob format)
- Added
- Unused Code Removal:
- Removed ~30 redundant function-level imports, unused variables, dead code, and trivial conditions flagged by CodeQL
- Local Security Scanner Improvements:
test_security.shuses--threads=0for all CodeQL commands (auto-detects CPU cores)- Added
.trivyignoreto suppress accepted Dockerfile USER directive finding
Enhancements
- Per-Filament Spoolman Usage Tracking (PR #277):
- Reports exact filament consumption per spool to Spoolman after each print
- Parses G-code from 3MF files for layer-by-layer extrusion data (multi-material support)
- New setting: "Disable AMS Estimated Weight Sync" to prefer Spoolman usage tracking over AMS weight estimates
- New setting: "Report Partial Usage for Failed Prints" estimates filament used up to the failure point based on layer progress
- Persists tracking data in SQLite for reliability across restarts
- Extracted Spoolman tracking into dedicated service module with DRY helpers
- 3D Model Viewer Improvements (PR #262):
- Added plate selector for multi-plate 3MF files with thumbnail previews
- Object count display shows number of objects per plate and total
- Fullscreen toggle for immersive model viewing
- Resizable split view between plate selector and 3D viewer in fullscreen mode
- Pagination support for files with many plates (e.g., 50+ plates)
- Added i18n translations for all model viewer strings (English, German, Japanese)
- Virtual Printer Proxy Mode Improvements:
- SSDP proxy for cross-network setups: select slicer network interface for automatic printer discovery via SSDP relay
- FTP proxy now listens on privileged port 990 (matching Bambu Studio expectations) instead of 9990
- For systemd: requires
AmbientCapabilities=CAP_NET_BIND_SERVICEcapability - Automatic directory permission checking at startup with clear error messages for Docker/bare metal
- Updated translations for proxy mode steps in English, German, and Japanese
Fixed
- Authentication Required Error After Initial Setup (Issue #257):
- Fixed "Authentication required" error when using printer controls after fresh install with auth enabled
- Token clearing on 401 responses is now more selective - only clears on invalid token messages
- Generic "Authentication required" errors (which may be timing issues) no longer clear the token
- Also fixed smart plug discovery scan endpoints missing auth headers
- Filament Hover Card Overlapping Navigation Bar (Issue #259):
- Fixed filament info popup being partially covered by the navigation bar
- Hover card positioning now accounts for the fixed 56px header
- Cards near the top of the page now correctly flip to show below the slot
- Filament Statistics Incorrectly Multiplied by Quantity (Issue #229):
- Fixed filament totals being inflated by incorrectly multiplying by quantity
- The
filament_used_gramsfield already contains the total for the entire print job - Removed incorrect
* quantitymultiplication from archive stats, Prometheus metrics, and FilamentTrends chart - Example: A print with 26 objects using 126g was incorrectly shown as 3,276g
- Print Queue Status Does Not Match Printer Status (Issue #249):
- Queue now shows "Paused" when the printer is paused instead of "Printing"
- Fetches real-time printer state for actively printing queue items
- Added translations for paused status in English, German, and Japanese
- Queue Scheduled Time Displayed in Wrong Timezone (Issue #233):
- Fixed scheduled time being displayed in UTC instead of local timezone when editing queue items
- The datetime picker now correctly shows and saves times in the user's local timezone
- Mobile Layout Issues on Archives and Statistics Pages (Issue #255):
- Fixed header buttons overflowing outside the screen on iPhone/mobile devices
- Headers now stack vertically on small screens with proper wrapping
- Applied consistent responsive pattern from PrintersPage
- AMS Auto-Matching Selects Wrong Slot (Issue #245):
- Fixed AMS slot mapping when multiple trays have the same
tray_info_idx(filament type identifier) tray_info_idx(e.g., "GFA00" for generic PLA) identifies filament TYPE, not unique spools- When multiple trays match the same type, color is now used as a tiebreaker
- Previously used
find()which always returned the first match regardless of color - Fixed in both backend (print_scheduler.py) and frontend (useFilamentMapping.ts)
- Resolves wrong tray selection (e.g., A4 instead of B1) when multiple AMS units have same filament type
- Fixed AMS slot mapping when multiple trays have the same
- A1/A1 Mini FTP Upload Failures (Issue #271):
- Fixed FTP uploads hanging/timing out on A1 and A1 Mini printers
- Replaced
storbinary()with manual chunked transfer usingtransfercmd() - A1's FTP server has issues with Python's
storbinary()waiting for completion response - Uses 1MB chunks with explicit 120s socket timeout for reliable transfers
- Works for all printer models (X1C, P1S, P1P, A1, A1 Mini)
- P1S/P1P FTP Upload Failures:
- Fixed FTP uploads failing with EOFError on P1S and P1P printers
- These printers use vsFTPd which requires SSL session reuse on data channel
- Removed P1S/P1P from skip-session-reuse list (they were incorrectly added)
- FTP Auto-Detection for A1 Printers:
- Automatically detects working FTP mode (prot_p vs prot_c) for A1/A1 Mini
- Tries encrypted data channel first, falls back to clear if needed
- Caches working mode per printer IP to avoid repeated detection
- Safari Camera Stream Failing:
- Fixed camera streams not loading in Safari due to Service Worker error
- Safari has stricter Service Worker scope requirements
- Queue Print Time for Multi-Plate Files (PR #274):
- Fixed print time showing total for all plates instead of selected plate
- Now extracts per-plate print time from 3MF slice_info.config
- Contributed by MisterBeardy
- Docker Permissions:
- Added user directive to docker-compose.yml using PUID/PGID environment variables
- Allows container to run as host user, fixing permission issues with bind-mounted volumes
- Usage:
PUID=$(id -u) PGID=$(id -g) docker compose up -d
Added
- Windows Portable Launcher (contributed by nmori):
- New
start_bambuddy.batfor Windows users - double-click to run, no installation required - Automatically downloads Python 3.13 and Node.js 22 on first run (portable, no system changes)
- Everything stored in
.portable\folder for easy cleanup - Commands:
start_bambuddy.bat(launch),start_bambuddy.bat update(update deps),start_bambuddy.bat reset(clean start) - Custom port via
set PORT=9000 & start_bambuddy.bat - Verifies all downloads with SHA256 checksums for security
- Supports both x64 and ARM64 Windows systems
- New
[0.1.7] - 2026-02-03
Security
- Critical: Missing API Endpoint Authentication (CVE-2026-25505, CVSS 9.8):
- Added authentication to 200+ API endpoints that were previously unprotected
- All route files now use
RequirePermissionIfAuthEnabled()for permission checks - Protected endpoints: archives, projects, settings, API keys, groups, cloud, notifications, maintenance, filaments, external links, smart plugs, discovery, firmware, camera, k-profiles, AMS history, pending uploads, updates, spoolman, system, print queue, printers
- Image-serving endpoints (thumbnails, timelapse, photos, camera streams) remain public as they require knowing the resource ID and are loaded via
<img>tags which cannot send Authorization headers - Backend integration tests added to verify endpoint authentication enforcement
Enhancements
- TOTP Authenticator Support for Bambu Cloud (Issue #182):
- Added support for TOTP-based two-factor authentication when connecting to Bambu Cloud
- Accounts with authenticator apps (Google Authenticator, Authy, etc.) now work correctly
- Proper detection of verification type: email code vs TOTP code
- Uses browser-like headers to bypass Cloudflare protection on TFA endpoint
- Frontend shows appropriate message for each verification type
- Added translations for TOTP UI in English, German, and Japanese
- Spoolman: Open in Spoolman Button (Issue #210):
- FilamentHoverCard now shows "Open in Spoolman" button when spool is already linked in Spoolman
- Button links directly to the spool's page in Spoolman for quick editing
- "Link to Spoolman" button now only shows when spool is not yet linked
- Link button correctly disabled when no unlinked spools are available in Spoolman
- Toast notification shown on successful/failed spool linking
- Added
/api/v1/spoolman/spools/linkedendpoint returning map of linked spool tags to IDs
- Complete German Translations:
- All UI strings now fully translated to German (1800+ translation keys)
- Pages translated: Settings, Archives, File Manager, Queue, Printers, Profiles, Projects, Stats, Maintenance, Camera, Groups, Users, Login, Setup, Stream Overlay
- Components translated: ConfirmModal, LinkSpoolModal, FilamentHoverCard, Layout
- Added locale parity test to ensure English and German stay in sync
- Virtual Printer Proxy Mode:
- New "Proxy" mode allows remote printing over any network by relaying slicer traffic to a real printer
- Configure a target printer and Bambuddy acts as a TLS proxy between your slicer and the printer
- Supports both FTP (port 990) and MQTT (port 8883) protocols with full TLS encryption
- Slicer connects to Bambuddy using the real printer's access code
- Real-time status display showing active FTP/MQTT connections
- Target printer selector with validation (must be configured in Bambuddy)
- Proxy mode bypasses the access code requirement (uses the real printer's credentials)
- Full i18n support for all proxy mode UI strings (English, German, Japanese)
Fixed
- Cannot Link Multiple HA Entities to Same Printer (Issue #214):
- Fixed Home Assistant entities being limited to one per printer
- Both frontend and backend were blocking printers that already had any smart plug linked
- Now only Tasmota plugs are limited to one per printer (physical device constraint)
- Multiple HA entities (switches, scripts, lights, etc.) can be linked to the same printer
- Restored "Show on Printer Card" toggle for HA entities to control visibility on printer cards
- Fixed printer card only showing
script.*entities; now shows all HA entities with toggle enabled - HA entities now default to auto_on=False and auto_off=False (appropriate for automations)
- Printer cards now update immediately when HA entities are added/modified/deleted
- Monthly Comparison Calculation Off (Issue #229):
- Fixed filament statistics not accounting for quantity multiplier
- Monthly comparison chart now correctly multiplies
filament_used_gramsbyquantity - Daily and weekly charts also now account for quantity
- Filament type breakdown includes quantity in calculations
- Backend stats endpoint (
/archives/stats) and Prometheus metrics also fixed - Prints count now shows total items (sum of quantities) instead of archive count
- Authentication Required for Downloads (Issue #231):
- Fixed support bundle download returning 401 Unauthorized when auth is enabled
- Fixed archive export (CSV/XLSX) failing with authentication enabled
- Fixed statistics export failing with authentication enabled
- Fixed printer file ZIP download failing with authentication enabled
- Root cause: These endpoints used raw
fetch()without Authorization header
- Queue Schedule Date Picker Ignores User Format Settings (Issue #233):
- Replaced native datetime picker with custom date/time inputs respecting user settings
- Date input shows in user's format (DD/MM/YYYY for EU, MM/DD/YYYY for US, YYYY-MM-DD for ISO)
- Time input shows in user's format (24H or 12H with AM/PM)
- Calendar button opens native picker for convenience; selection is formatted to user's preference
- Placeholder text shows expected format (e.g., "DD/MM/YYYY" or "HH:MM AM/PM")
- Added date utilities:
formatDateInput,parseDateInput,getDatePlaceholder - Added time utilities:
formatTimeInput,parseTimeInput,getTimePlaceholder
- 500 Error on Archive Detail Page:
- Fixed internal server error when viewing individual archive details
- Root cause:
projectrelationship not eagerly loaded inget_archive()service method - Async SQLAlchemy requires explicit eager loading; lazy loading is not supported
[0.1.6.2] - 2026-02-02
Security Release: This release addresses critical security vulnerabilities. Users running authentication-enabled instances should upgrade immediately.
Security
- Critical: Hardcoded JWT Secret Key (GHSA-gc24-px2r-5qmf, CWE-321) - Fixed hardcoded JWT secret key that could allow attackers to forge authentication tokens:
- JWT secret now loaded from
JWT_SECRET_KEYenvironment variable (recommended for production) - Falls back to auto-generated
.jwt_secretfile in data directory with secure permissions (0600) - Generates cryptographically secure 64-byte random secret if neither exists
- Action Required: Existing users will need to re-login after upgrading
- JWT secret now loaded from
- Critical: Missing API Authentication (GHSA-gc24-px2r-5qmf, CWE-306) - Fixed 77+ API endpoints that lacked authentication checks:
- Added HTTP middleware enforcing authentication on ALL
/api/routes when auth is enabled - Only essential public endpoints are exempt (login, auth status, version check, WebSocket)
- All other API calls now require valid JWT token or API key
- Added HTTP middleware enforcing authentication on ALL
Enhancements
- Location Filter for Queue (Issue #220):
- Filter queue jobs by printer location in the Queue page
- "Any {Model}" queue assignments can now specify a target location (e.g., "Any X1C in Workshop")
- Location filter dropdown shows all unique locations from printers and queue items
- Location is saved with queue items and displayed in the queue list
- Ownership-Based Permissions (Issue #205):
- Users can now only update/delete their own items unless they have elevated permissions
- Update/delete permissions split into
*_ownand*_allvariants:queue:update_own/queue:update_allqueue:delete_own/queue:delete_allarchives:update_own/archives:update_allarchives:delete_own/archives:delete_allarchives:reprint_own/archives:reprint_alllibrary:update_own/library:update_alllibrary:delete_own/library:delete_all
- Administrators group gets
*_allpermissions (can modify any items) - Operators group gets
*_ownpermissions (can only modify their own items) - Ownerless items (legacy data without creator) require
*_allpermission - Bulk operations skip items user doesn't have permission to modify
- User deletion now offers choice: delete user's items or keep them (become ownerless)
- Backend enforces permissions on all API endpoints (not just frontend UI)
- Automatic migration upgrades existing groups to new permission model
- User Tracking for Archives, Library & Queue (Issue #206):
- Track and display who uploaded each archive file
- Track and display who uploaded each library file (File Manager)
- Track and display who added each print job to the queue
- Shows username on archive cards, library files, queue items, and printer cards (while printing)
- Works when authentication is enabled; gracefully hidden when auth is disabled
- Database migration adds
created_by_idcolumns toprint_archives,library_files, andprint_queuetables
- Separate AMS RFID Permission (Issue #204):
- Added new
printers:ams_rfidpermission for re-reading AMS RFID tags - Allows granting RFID re-read access without full printer control permissions
- Operators group includes this permission by default
- Available in Settings > Users > Group Editor as a toggleable permission
- Added new
- Schedule Button on Archive Cards (Issue #208):
- Added "Schedule" button next to "Reprint" on archive cards for quick access to print scheduling
- Previously only available in the context menu (right-click)
- Respects
queue:createpermission for users with restricted access
- Streaming Overlay Improvements (Issue #164):
- Configurable FPS: Add
?fps=30parameter to control camera frame rate (1-30, default 15) - Status-only mode: Add
?camera=falseparameter to hide camera and show only status overlay on black background - Increased default camera FPS from 10 to 15 for smoother video across all camera views
- Configurable FPS: Add
- Simplified Backup/Restore System:
- Complete backup now creates a single ZIP file containing the entire database and all data directories
- Includes: database, archives, library files, thumbnails, timelapses, icons, projects, and plate calibration data
- Portable backups: works across different installations and data directories
- Faster backup/restore: direct file copy instead of JSON export/import
- Progress indicator and navigation blocking during backup/restore operations
- Removed ~2000 lines of legacy JSON-based backup/restore code
Fixes
- File Manager permissions not enforced (Issue #224) - Fixed backend not checking
library:readpermission for File Manager endpoints:- Added
library:readpermission check to all list/view endpoints (files, folders, stats) - Added
library:uploadpermission check to upload and folder creation endpoints - Added
queue:createpermission check to add-to-queue endpoint - Added
printers:controlpermission check to direct print endpoint - Added ownership-based permission checks to file move operation
- Users without
library:readpermission can no longer view files in the File Manager - Users can now only delete/update their own files unless they have
*_allpermissions
- Added
- JWT secret key not persistent across restarts - Fixed JWT secret key generation to properly use data directory, ensuring tokens remain valid across container restarts
- Images/thumbnails returning 401 when auth enabled - Fixed auth middleware to allow public access to image/media endpoints (thumbnails, photos, QR codes, timelapses, camera streams) since browser elements like
<img>don't send Authorization headers - Library thumbnails missing after restore - Fixed library files using absolute paths that break after restore on different systems:
- Library now stores relative paths in database for portability
- Automatic migration converts existing absolute paths to relative on startup
- Thumbnails and files now display correctly after restoring backups
- File uploads failing with authentication enabled - Fixed all file upload functions (archives, photos, timelapses, library files, etc.) not sending authentication headers when auth is enabled
- External spool AMS mapping causing "Failed to get AMS mapping table" (Issue #213) - Fixed external spool
ams_mapping2slot_id handling that caused AMS mapping failures - Filename matching for files with spaces (Issue #218) - Fixed file detection when filenames contain spaces
- P2S FTP upload failure (Issue #218) - Fixed FTP uploads to P2S printers by passing
skip_session_reuseto ImplicitFTP_TLS - Printer deletion freeze (Issue #214) - Fixed UI freeze when deleting printers, and now allows multiple smart plugs per printer
- Stack trace exposure in error responses (CodeQL Alert #68) - Fixed stack traces being exposed in API error responses in archives.py
- Printer serial numbers exposed in support bundle (Issue #216) - Sanitized printer serial numbers in support bundle logs for privacy
- Missing sliced_for_model migration (Issue #211) - Fixed database migration for
sliced_for_modelcolumn that was missing in some upgrade paths
[0.1.6-final] - 2026-01-31
New Features
- Group-Based Permissions - Granular access control with user groups:
- Create custom groups with specific permissions (50+ granular permissions)
- Default system groups: Administrators (full access), Operators (control printers), Viewers (read-only)
- Users can belong to multiple groups with additive permissions
- Permission-based UI: buttons/features disabled when user lacks permission
- Groups management page in Settings → Users → Groups tab
- Change password: users can change their own password from sidebar
- Included in backup/restore
- STL Thumbnail Generation - Auto-generate preview thumbnails for STL files (Issue #156):
- Checkbox option when uploading STL files to generate thumbnails automatically
- Batch generate thumbnails for existing STL files via "Generate Thumbnails" button
- Individual file thumbnail generation via context menu (three-dot menu)
- Works with ZIP extraction (generates thumbnails for all STL files in archive)
- Uses trimesh and matplotlib for 3D rendering with Bambu green color theme
- Thumbnails auto-refresh in UI after generation
- Graceful handling of complex/invalid STL files
- Streaming Overlay for OBS - Embeddable overlay page for live streaming with camera and print status (Issue #164):
- All-in-one page at
/overlay/:printerIdcombining camera feed with status overlay - Real-time print progress, ETA, layer count, and filename display
- Bambuddy logo branding (links to GitHub)
- Customizable via query parameters:
?size=small|medium|largeand?show=progress,layers,eta,filename,status,printer - No authentication required - designed for OBS browser source embedding
- Gradient overlay at bottom for readable text over camera feed
- Auto-reconnect on camera stream errors
- All-in-one page at
- MQTT Smart Plug Support - Add smart plugs that subscribe to MQTT topics for energy monitoring (Issue #173):
- New "MQTT" plug type alongside Tasmota and Home Assistant
- Subscribe to any MQTT topic (Zigbee2MQTT, Shelly, Tasmota discovery, etc.)
- Separate topics per data type: Configure different MQTT topics for power, energy, and state
- Configurable JSON paths for data extraction (e.g.,
power_l1,data.power) - Separate multipliers: Individual multiplier for power and energy (e.g., mW→W, Wh→kWh)
- Custom ON value: Configure what value means "ON" for state (e.g., "ON", "true", "1")
- Monitor-only: displays power/energy data without control capabilities
- Reuses existing MQTT broker settings from Settings → Network
- Energy data included in statistics and per-print tracking
- Full backup/restore support for MQTT plug configurations
- Disable Printer Firmware Checks - New toggle in Settings → General → Updates to disable printer firmware update checks:
- Prevents Bambuddy from checking Bambu Lab servers for firmware updates
- Useful for users who prefer to manage firmware manually or have network restrictions
- Archive Plate Browsing - Browse plate thumbnails directly in archive cards (Issue #166):
- Hover over archive card to reveal plate navigation for multi-plate files
- Left/right arrows to cycle through plate thumbnails
- Dot indicators show current plate (clickable to jump to specific plate)
- Lazy-loads plate data only when user hovers
- GitHub Profile Backup - Automatically backup your Cloud profiles, K-profiles and settings to a GitHub repository:
- Configure GitHub repository URL and Personal Access Token
- Schedule backups hourly, daily, or weekly
- Manual on-demand backup trigger
- Backs up K-profiles (per-printer), cloud profiles, and app settings
- Skip unchanged commits (only creates commit when data changes)
- Real-time progress tracking during backup
- Backup history log with status and commit links
- Requires Bambu Cloud login for full profile access
- New Settings → Backup & Restore tab (local backup/restore moved here)
- Included in local backup/restore (except PAT for security)
- Plate Not Empty Notification - Dedicated notification category for build plate detection:
- New toggle in notification provider settings (enabled by default)
- Sends immediately (bypasses quiet hours and digest mode)
- Separate from general printer errors for granular control
- USB Camera Support - Connect USB webcams directly to your Bambuddy host:
- New "USB Camera (V4L2)" option in external camera settings
- Auto-detection of available USB cameras via V4L2
- API endpoint to list connected USB cameras (
GET /api/v1/printers/usb-cameras) - Works with any V4L2-compatible camera on Linux
- Uses ffmpeg for frame capture and streaming
- Build Plate Empty Detection - Automatically detect if objects are on the build plate before printing:
- Per-printer toggle to enable/disable plate detection
- Multi-reference calibration: Store up to 5 reference images of empty plates (different plate types)
- Automatic print pause when objects detected on plate at print start
- Push notification and WebSocket alert when print is paused due to plate detection
- ROI (Region of Interest) calibration UI with sliders to focus detection on build plate area
- Reference management: View thumbnails, add labels, delete references
- Works with both built-in and external cameras
- Uses buffered camera frames when stream is active (no blocking)
- Split button UI: Main button toggles detection on/off, chevron opens calibration modal
- Green visual indicator when plate detection is enabled
- Included in backup/restore
- Project Import/Export - Export and import projects with full file support (Issue #152):
- Export single project as ZIP (includes project settings, BOM, and all files from linked library folders)
- Export all projects as JSON for metadata-only backup
- Import from ZIP (with files) or JSON (metadata only)
- Linked folders and files are automatically created on import
- Useful for sharing complete project bundles or migrating between instances
- BOM Item Editing - Bill of Materials items are now fully editable:
- Edit name, quantity, price, URL, and remarks after creation
- Pencil icon on each BOM item to enter edit mode
- Prometheus Metrics Endpoint - Export printer telemetry for external monitoring systems (Issue #161):
- Enable via Settings → Network → Prometheus Metrics
- Endpoint:
GET /api/v1/metrics(Prometheus text format) - Optional bearer token authentication for security
- Printer metrics: connection status, state, temperatures (bed, nozzle, chamber), fans, WiFi signal
- Print metrics: progress, remaining time, layer count
- Statistics: total prints by status, filament used, print time
- Queue metrics: pending and active jobs
- System metrics: connected printers count
- Labels include printer_id, printer_name, serial for filtering
- Ready for Grafana dashboards
- External Link for Archives - Add custom external links to archives for non-MakerWorld sources (Issue #151):
- Link archives to Printables, Thingiverse, or any other URL
- Globe button opens external link when set, falls back to auto-detected MakerWorld URL
- Edit via archive edit modal
- Included in backup/restore
- External Network Camera Support - Add external cameras (MJPEG, RTSP, HTTP snapshot) to replace built-in printer cameras (Issue #143):
- Configure per-printer external camera URL and type in Settings → Camera
- Live streaming uses external camera when enabled
- Finish photo capture uses external camera
- Layer-based timelapse: captures frame on each layer change, stitches to MP4 on print completion
- Test connection button to verify camera accessibility
- Recalculate Costs Button - New button on Dashboard to recalculate all archive costs using current filament prices (Issue #120)
- Create Folder from ZIP - New option in File Manager upload to automatically create a folder named after the ZIP file (Issue #121)
- Multi-File Selection in Printer Files - Printer card file browser now supports multiple file selection (Issue #144):
- Checkbox selection for individual files
- Select All / Deselect All buttons
- Bulk download as ZIP when multiple files selected
- Bulk delete for multiple files at once
- Queue Bulk Edit - Select and edit multiple queue items at once (Issue #159):
- Checkbox selection for pending queue items
- Select All / Deselect All in toolbar
- Bulk edit: printer assignment, print options, queue options
- Bulk cancel selected items
- Tri-state toggles: unchanged / on / off for each setting
Fixes
- Multi-Plate Thumbnail in Queue - Fixed queue items showing wrong thumbnail for multi-plate files (Issue #166):
- Queue now displays the correct plate thumbnail based on selected plate
- Previously always showed plate 1 thumbnail regardless of selection
- A1/A1 Mini Shows Printing Instead of Idle - Fixed incorrect status display for A1 series printers (Issue #168):
- Some A1/A1 Mini firmware versions incorrectly report stage 0 ("Printing") when idle
- Now checks gcode_state to correctly display "Idle" for affected printers
- Fix only applies to A1 models with the specific buggy condition
- HMS Error Notifications - Get notified when printer errors occur (Issue #84):
- Automatic notifications for HMS errors (AMS issues, nozzle problems, etc.)
- Human-readable error messages (853 error codes translated)
- Friendly error type names (Print/Task, AMS/Filament, Nozzle/Extruder, Motion Controller, Chamber)
- Deduplication prevents spam from repeated error messages
- Publishes to MQTT relay for home automation integrations
- New "Printer Error" toggle in notification provider settings
- Plate Calibration Persistence - Fixed plate detection reference images not persisting after restart in Docker deployments
- Telegram Notification Parsing - Fixed Telegram markdown parsing errors when messages contain underscores (e.g., error codes)
- Settings API PATCH Method - Added PATCH support to
/api/settingsfor Home Assistant rest_command compatibility (Issue #152) - P2S Empty Archive Tiles - Fixed FTP file search for printers without SD card (Issue #146):
- Added root folder
/to search paths when looking for 3MF files - Printers without SD card store files in root instead of
/cache
- Added root folder
- Empty AMS Slot Not Recognized - Fixed bug where removed spools still appeared in Bambuddy (Issue #147):
- Old AMS: Now properly applies empty values from tray data updates
- New AMS (AMS 2 Pro): Now checks
tray_exist_bitsbitmask to detect and clear empty slots
- Reprint Cost Tracking - Reprinting an archive now adds the cost to the existing total, so statistics accurately reflect total filament expenditure across all prints
- HA Energy Sensors Not Detected - Home Assistant energy sensors with lowercase units (w, kwh) are now properly detected; unit matching is now case-insensitive (Issue #119)
- File Manager Upload - Upload modal now accepts all file types, not just ZIP files
- Camera Zoom & Pan Improvements - Enhanced camera viewer zoom/pan functionality (Issue #132):
- Pan range now based on actual container size, allowing full navigation of zoomed image
- Added pinch-to-zoom support for mobile/touch devices
- Added touch-based panning when zoomed in
- Both embedded camera viewer and standalone camera page updated
- Progress Milestone Time - Fixed milestone notifications showing wrong time (e.g., "17m" instead of "17h 47m") by converting remaining_time from minutes to seconds (Issue #157)
- File Manager Folder Navigation - Improved handling of long folder names (Issue #160):
- Resizable sidebar: Drag the edge to adjust width (200-500px), double-click to reset
- Text wrap toggle: "Wrap" button in header to wrap long names instead of truncating
- Both settings persist in localStorage
- Tooltip shows full name on hover
- K-Profiles Backup Status - Fixed GitHub backup settings showing incorrect printer connection count (e.g., "1/2 connected" when both printers are connected); now fetches status from API instead of relying on WebSocket cache
- GitHub Backup Timestamps - Removed volatile timestamps from GitHub backup files so git diffs only show actual data changes
- Model-Based Queue AMS Mapping - Fixed "Any [Model]" queue jobs failing at filament loading on H2D Pro and other printers (Issue #192):
- Scheduler now computes AMS mapping after printer assignment for model-based jobs
- Previously, no AMS mapping was sent because the specific printer wasn't known at queue time
- Auto-matches required filaments to available AMS slots by type and color
Maintenance
- Upgraded vitest from 2.x to 3.x to resolve npm audit security vulnerabilities in dev dependencies
[0.1.6b11] - 2026-01-22
New Features
- Camera Zoom & Fullscreen - Enhanced camera viewer controls:
- Fullscreen mode for embedded camera viewer (new button in header)
- Zoom controls (100%-400%) for both embedded and window modes
- Pan support when zoomed in (click and drag)
- Mouse wheel zoom support
- Zoom resets on mode switch, refresh, or fullscreen toggle
- Searchable HA Entity Selection - Improved Home Assistant smart plug configuration:
- Entity dropdown replaced with searchable combobox
- Type to search across all HA entities (not just switch/light/input_boolean)
- Energy sensor dropdowns (Power, Energy Today, Total) are now searchable
- Find sensors with non-standard naming that don't match the switch entity name
- Home Assistant Energy Sensor Support - HA smart plugs can now use separate sensor entities for energy monitoring:
- Configure dedicated power sensor (W), today's energy (kWh), and total energy (kWh) sensors
- Supports plugs where energy data is exposed as separate sensor entities (common with Tapo, IKEA Zigbee2mqtt, etc.)
- Energy sensors are selectable from all available HA sensors with power/energy units
- Falls back to switch entity attributes if no sensors configured
- Print energy tracking now works correctly for HA plugs (not just Tasmota)
- New API endpoint:
GET /api/v1/smart-plugs/ha/sensorsto list available energy sensors
- Finish Photo in Notifications - Camera snapshot URL available in notification templates (Issue #126):
- New
{finish_photo_url}template variable for print_complete, print_failed, print_stopped events - Photo is captured before notification is sent (ensures image is available)
- New "External URL" setting in Settings → Network (auto-detects from browser)
- Full URL constructed for external notification services (Telegram, Email, Discord, etc.)
- New
- ZIP File Support in File Manager - Upload and extract ZIP files directly in the library (Issue #121):
- Drop or select ZIP files to automatically extract contents
- Option to preserve folder structure from ZIP or extract flat
- Extracts thumbnails and metadata from 3MF/gcode files inside ZIP
- Progress indicator shows number of files extracted
Fixed
- Print time stats using slicer estimates - Quick Stats "Print Time" now uses actual elapsed time (
completed_at - started_at) instead of slicer estimates; cancelled prints only count time actually printed (Issue #137) - Skip objects modal overflow - Modal now has max height (85vh) with scrollable object list when printing many items on the bed (Issue #134)
- Filament cost using wrong default - Statistics now correctly uses the "Default filament cost (per kg)" setting instead of hardcoded €25 value (Issue #120)
- Spoolman tag field not auto-created - The required "tag" extra field is now automatically created in Spoolman on first connect, fixing sync failures for fresh Spoolman installs (Issue #123)
- P2S/X1E/H2 completion photo not captured - Internal model codes (N7, C13, O1D, etc.) from MQTT/SSDP are now recognized for RTSP camera support (Issue #127)
- Mattermost/Slack webhook 400 error - Added "Slack / Mattermost" payload format option that sends
{"text": "..."}instead of custom fields (Issue #133) - Subnet scan serial number - Fixed A1 Mini subnet discovery showing "unknown-*" placeholder; serial field is now cleared so users know to enter it manually (Issue #140)
[0.1.6b10] - 2026-01-21
New Features
- Unified Print Modal - Consolidated three separate modals into one unified component:
- Single modal handles reprint, add-to-queue, and edit-queue-item operations
- Consistent UI/UX across all print operations
- Reduced code duplication (~1300 LOC removed)
- Multi-Printer Selection - Send prints or queue items to multiple printers at once:
- Checkbox selection for multiple printers in reprint and add-to-queue modes
- "Select all" / "Clear" buttons for quick selection
- Progress indicator during multi-printer submission
- Ideal for print farms with identical filament configurations
- Per-Printer AMS Mapping - Configure filament slot mapping individually for each printer:
- Enable "Custom mapping" checkbox under each selected printer
- Auto-configure uses RFID data to match filaments automatically
- Manual override for specific slot assignments
- Match status indicator shows exact/partial/missing matches
- Re-read button to refresh printer's loaded filaments
- New setting in Settings → Filament to expand custom mapping by default
- Enhanced Add-to-Queue - Now includes plate selection and print options:
- Configure all print settings upfront instead of editing afterward
- Filament mapping with manual override capability
- Print from File Manager - Full print configuration when printing from library files:
- Plate selection for multi-plate 3MF files with thumbnails
- Filament slot mapping with comparison to loaded filaments
- All print options (bed levelling, flow calibration, etc.)
- File Manager Print Button - Print directly from multi-selection toolbar:
- "Print" button appears when exactly one sliced file is selected
- Opens full PrintModal with plate selection and print options
- "Add to Queue" button now uses Clock icon for clarity
- Multiple Embedded Camera Viewers - Open camera streams for multiple printers simultaneously in embedded mode:
- Each viewer has its own remembered position and size
- New viewers are automatically offset to prevent stacking
- Printer-specific persistence in localStorage
- Navigation persistence - Open cameras stay open when navigating away and back to Printers page
- Application Log Viewer - View and filter application logs in real-time from System Information page:
- Start/Stop live streaming with 2-second auto-refresh
- Filter by log level (DEBUG, INFO, WARNING, ERROR)
- Text search across messages and logger names
- Clear logs with one click
- Expandable multi-line log entries (stack traces, etc.)
- Auto-scroll to follow new entries
- Deferred archive creation - Queue items from File Manager no longer create archives upfront:
- Queue items store
library_file_iddirectly - Archives are created automatically when prints start
- Reduces clutter in Archives from unprinted queued files
- Queue displays library file name, thumbnail, and print time
- Queue items store
- Expandable Color Picker - Configure AMS Slot modal now has an expandable color palette:
- 8 basic colors shown by default (White, Black, Red, Blue, Green, Yellow, Orange, Gray)
- Click "+" to expand 24 additional colors (Cyan, Magenta, Purple, Pink, Brown, Beige, Navy, Teal, Lime, Gold, Silver, Maroon, Olive, Coral, Salmon, Turquoise, Violet, Indigo, Chocolate, Tan, Slate, Charcoal, Ivory, Cream)
- Click "-" to collapse back to basic colors
- File Manager Sorting - Printer file manager now has sorting options:
- Sort by name (A-Z or Z-A)
- Sort by size (smallest or largest first)
- Sort by date (oldest or newest first)
- Directories always sorted first
- Camera View Mode Setting - Choose how camera streams open:
- "New Window" (default): Opens camera in a separate browser window
- "Embedded": Shows camera as a floating overlay on the main screen
- Embedded viewer is draggable and resizable with persistent position/size
- Configure in Settings → General → Camera section
- File Manager Rename - Rename files and folders directly in File Manager:
- Right-click context menu "Rename" option for files and folders
- Inline rename button in list view
- Validates filenames (no path separators allowed)
- File Manager Mobile Accessibility - Improved touch device support:
- Three-dot menu button always visible on mobile (hover-only on desktop)
- Selection checkbox always visible on mobile devices
- Better PWA experience for file management
- Optional Authentication - Secure your Bambuddy instance with user authentication:
- Enable/disable authentication via Setup page or Settings → Users
- Role-based access control: Admin and User roles
- Admins have full access; Users can manage prints but not settings
- JWT-based authentication with 7-day token expiration
- User management page for creating, editing, and deleting users
- Backward compatible: existing installations work without authentication
- Settings page restricted to admin users when auth is enabled
Changed
- Edit Queue Item modal - Single printer selection only (reassigns item, doesn't duplicate)
- Edit Queue Item button - Changed from "Print to X Printers" to "Save"
Fixed
- File Manager folder navigation - Fixed bug where opening a folder would briefly show files then jump back to root:
- Removed
selectedFolderIdfrom useEffect dependency array that was causing a reset loop - Folder navigation now works correctly without resetting
- Removed
- Queue items with library files - Fixed 500 errors when listing/updating queue items from File Manager
- User preset AMS configuration - Fixed user presets (inheriting from Bambu presets) showing empty fields in Bambu Studio after configuration:
- Now correctly derives
tray_info_idxfrom the preset'sbase_idwhenfilament_idis null - User presets that inherit from Bambu presets (e.g., "# Overture Matte PLA @BBL H2D") now work correctly
- Now correctly derives
- Faster AMS slot updates - Frontend now updates immediately after configuring AMS slots:
- Added WebSocket broadcast to AMS change callback for instant UI updates
- Removed unnecessary delayed refetch that was causing slow updates
[0.1.6b9] - 2026-01-19
New Features
- Add to Queue from File Manager - Queue sliced files directly from File Manager:
- New "Add to Queue" toolbar button appears when sliced files are selected
- Context menu and list view button options for individual files
- Supports multiple file selection for batch queueing
- Only accepts sliced files (.gcode or .gcode.3mf)
- Creates archive and queue item in one action
- Print Queue plate selection and options - Full print configuration in queue edit modal:
- Plate selection grid with thumbnails for multi-plate 3MF files
- Print options section (bed levelling, flow calibration, vibration calibration, layer inspect, timelapse, use AMS)
- Options saved with queue item and used when print starts
- Multi-plate 3MF plate selection - When reprinting multi-plate 3MF files (exported with "All sliced file"), users can now select which plate to print:
- Plate selection grid with thumbnails, names, and print times
- Filament requirements filtered to show only selected plate's filaments
- Prevents incorrect filament mapping across plates
- Closes #93
- Home Assistant smart plug integration - Control any Home Assistant switch/light entity as a smart plug:
- Configure HA connection (URL + Long-Lived Access Token) in Settings → Network
- Add HA-controlled plugs via Settings → Plugs → Add Smart Plug → Home Assistant tab
- Entity dropdown shows all available switch/light/input_boolean entities
- Full automation support: auto-on, auto-off, scheduling, power alerts
- Works alongside existing Tasmota plugs
- Closes #91
- Fusion 360 design file attachments - Attach F3D files to archives for complete design tracking:
- Upload F3D files via archive context menu ("Upload F3D" / "Replace F3D")
- Cyan badge on archive card indicates attached F3D file (next to source 3MF badge)
- Click badge to download, or use "Download F3D" in context menu
- F3D files included in backup/restore
- API tests for F3D endpoints
Fixed
- Multi-plate 3MF metadata extraction - Single-plate exports from multi-plate projects now show correct thumbnail and name:
- Extracts plate index from slice_info.config metadata
- Uses correct plate thumbnail (e.g., plate_5.png instead of plate_1.png)
- Appends "Plate N" to print name for plates > 1
- Closes #92
[0.1.6b8] - 2026-01-17
Added
- MQTT Publishing - Publish BamBuddy events to external MQTT brokers for integration with Home Assistant, Node-RED, and other automation platforms:
- New "Network" tab in Settings for MQTT configuration
- Configure broker, port, credentials, TLS, and topic prefix
- Real-time connection status indicator
- Topics: printer status, print lifecycle, AMS changes, queue events, maintenance alerts, smart plug states, archive events
- Virtual Printer Queue Mode - New mode that archives files and adds them directly to the print queue:
- Three modes: Archive (immediate), Review (pending list), Queue (print queue)
- Queue mode creates unassigned items that can be assigned to a printer later
- Unassigned Queue Items - Print queue now supports items without an assigned printer:
- "Unassigned" filter option on Queue page
- Unassigned items highlighted in orange
- Assign printer via edit modal
- Sidebar Badge Indicators - Visual indicators on sidebar icons:
- Queue icon: yellow badge with pending item count
- Archive icon: blue badge with pending uploads count
- Auto-updates every 5 seconds and on window focus
- Project Parts Tracking - Track individual parts/objects separately from print plates:
- "Target Parts" field alongside "Target Plates"
- Separate progress bars for plates vs parts
- Parts count auto-detected from 3MF files
Fixed
- Chamber temp on A1/P1S - Fixed regression where chamber temperature appeared on printers without sensors in multi-printer setups
- Queue prints on A1 - Fixed "MicroSD Card read/write exception error" when starting prints from queue
- Spoolman sync - Fixed Bambu Lab spool detection and AMS tray data persistence
- FTP downloads - Fixed downloads failing for .3mf files without .gcode extension
- Project statistics - Fixed inconsistent display between project list and detail views
- Chamber light state - Fixed WebSocket broadcasts not including light state changes
- Backup/restore - Improved handling of nullable fields and AMS mapping data
[0.1.6b7] - 2026-01-12
Added
- AMS Color Mapping - Manual AMS slot selection in ReprintModal, AddToQueueModal, EditQueueItemModal:
- Dropdown to override auto-matched AMS slots with any loaded filament
- Blue ring indicator distinguishes manual selections from auto-matches
- Status indicators: green (match), yellow (type only), orange (not found)
- Shared color utility with ~200 Bambu color mappings
- Fixed AMS mapping format to match Bambu Studio exactly
- Print Options in Reprint Modal - Bed leveling, flow calibration, vibration calibration, first layer inspection, timelapse toggles
- Time Format Setting - New date utilities applied to 12 components, fixes archive times showing in UTC
- Statistics Dashboard Improvements - Size-aware rendering for PrintCalendar, SuccessRateWidget, TimeAccuracyWidget, FilamentTypesWidget, FailureAnalysisWidget
- Firmware Update Helper - Check firmware versions against Bambu Lab servers for LAN-only printers with one-click upload
- FTP Reliability - Configurable retry (1-10 attempts, 1-30s delay), A1/A1 Mini SSL fix, configurable timeout
- Bulk Project Assignment - Assign multiple archives to a project at once from multi-select toolbar
- Chamber Light Control - Light toggle button on printer cards
- Support Bundle Feature - Debug logging toggle with ZIP generation for issue reporting
- Archive Improvements - List view with full parity, object count display, cross-view highlighting, context menu button
- Maintenance Improvements - wiki_url field for documentation links, model-specific Bambu Lab wiki URLs
- Spoolman Integration - Clear location when spools removed from AMS during sync
Fixed
- Browser freeze from CameraPage WebSocket
- Project card filament badges showing duplicates and raw color codes
- Print object label positioning in skip objects modal
- Printer hour counter not updated on backend restart
- Virtual printer excluded from discovery
- Print cover fetch in Docker environments
- Archive delete safety checks prevent deleting parent dirs
[0.1.6b6] - 2026-01-04
Added
- Resizable Printer Cards - Four sizes (S/M/L/XL) with +/- buttons in toolbar
- Queue Only Mode - Stage prints without auto-start, release when ready with purple "Staged" badge
- Virtual Printer Model Selection - Choose which Bambu printer model to emulate
- Tasmota Admin Link - Quick access to smart plug web interface with auto-login
- Pending Upload Delete Confirmation - Confirmation modal when discarding pending uploads
Fixed
- Camera stream reconnection with automatic recovery from stalled streams
- Active AMS slot display for H2D printers with multiple AMS units
- Spoolman sync matching only Bambu Lab vendor filaments
- Skip objects modal object ID markers positioning
- Virtual printer model codes, serial prefixes, startup model, certificate persistence
- Archive card context menu positioning
[0.1.6b5] - 2026-01-02
Added
- Pre-built Docker Images - Pull directly from GitHub Container Registry (ghcr.io)
- Printer Controls - Stop and Pause/Resume buttons on printer cards with confirmation modals
- Skip Objects - Skip individual objects during print without canceling entire job
- Spoolman Improvements - Link Spool, UUID Display, Sync Feedback
- AMS Slot RFID Re-read - Re-read filament info via hover menu
- Print Quantity Tracking - Track items per print for project progress
Fixed
- Spoolman 400 Bad Request when creating spools
- Update module for Docker based installations
[0.1.6b4] - 2026-01-01
Changed
- Refactored AMS section for better visual grouping and spacing
Fixed
- Printer hour counter not incrementing during prints
- Slicer protocol OS detection (Windows: bambustudio://, macOS/Linux: bambustudioopen://)
- Camera popup window auto-resize and position persistence
- Maintenance page duration display with better precision
- Docker update detection for in-app updates
[0.1.6b3] - 2025-12-31
Added
- Confirmation modal for quick power switch in sidebar
Fixed
- Printer hour counter inconsistency between card and maintenance page
- Improved printer hour tracking accuracy with real-time runtime counter
- Add Smart Plug modal scrolling on lower resolution screens
- Excluded virtual printer from discovery results
- Bottom sidebar layout
[0.1.6b2] - 2025-12-29
Added
- Virtual Printer - Emulates a Bambu Lab printer on your network:
- Auto-discovery via SSDP protocol
- Send prints directly from Bambu Studio/Orca Slicer
- Queue mode or Auto-start mode
- TLS 1.3 encrypted MQTT + FTPS with auto-generated certificates
- Persistent archive page filters
Fixed
- AMS filament matching in reprint modal
- Archive card cache bug with wrong cover image
- Queueing module re-queue modal
[0.1.6b] - 2025-12-28
Added
- Smart Plugs - Tasmota device discovery and Switchbar quick access widget
- Timelapse Editor - Trim, speed adjustment (0.25x-4x), and music overlay
- Printer Discovery - Docker subnet scanning, printer model mapping, detailed status stages
- Archives & Projects - AMS filament preview, file type badges, project filament colors, BOM filter
- Maintenance - Custom maintenance types with manual per-printer assignment
- Delete printer options to keep or delete archives
Fixed
- Notifications sent when printer offline
- Camera stream stopping with auto-reconnection
- A1/P1 camera streaming with extended timeouts
- Attachment uploads not persisting
- Total print hours calculation
[0.1.5] - 2025-12-19
Added
- Docker Support - One-command deployment with docker compose
- Mobile PWA - Full mobile support with responsive navigation and touch gestures
- Projects - Group related prints with progress tracking
- Archive Comparison - Compare 2-5 archives side-by-side
- Smart Plug Automation - Tasmota integration with auto power-on/off
- Telemetry Dashboard - Anonymous usage statistics (opt-out available)
- Full-Text Search - Efficient search across print names, filenames, tags, notes, designer, filament type
- Failure Analysis - Dashboard widget showing failure rate with correlations and trends
- CSV/Excel Export - Export archives and statistics with current filters
- AMS Humidity/Temperature History - Clickable indicators with charts and statistics
- Daily Digest Notifications - Consolidated daily summary
- Notification Template System - Customizable message templates
- Webhooks & API Keys - API key authentication with granular permissions
- System Info Page - Database and resource statistics
- Comprehensive Backup/Restore - Including user options and external links
Changed
- Redesigned AMS section with BambuStudio-style device icons
- Tabbed design and auto-save for settings page
- Improved archive card context menu with submenu support
- WebSocket throttle reduced to 100ms for smoother updates
Fixed
- Browser freeze on print completion when camera stream was open
- Printer status "timelapse" effect after print completion
- Complete rewrite of timelapse auto-download with retry mechanism
- Reprint from archive sending slicer source file instead of sliced gcode
- Import shadowing bugs causing "cannot access local variable" error
- Archive PATCH 500 error
- ffmpeg processes not killed when closing webcam window
Removed
- Control page
- PWA push notifications (replaced with standard notification providers)