Note
This is a daily beta build (2026-07-21). It contains the latest fixes and improvements but may have undiscovered issues.
Docker users: Update by pulling the new image:
docker pull ghcr.io/maziggy/bambuddy:daily
or
docker pull maziggy/bambuddy:daily
**Tip:** Use [Watchtower](https://containrrr.dev/watchtower/) to automatically update when new daily builds are pushed.
Added
- 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
- 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 real
Changelog truncated — see the full CHANGELOG.md for the complete list.