github maziggy/bambuddy v1.2.5b2-daily.20260718
Daily Beta Build v1.2.5b2-daily.20260718

pre-release16 hours ago

Note

This is a daily beta build (2026-07-18). 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.

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 localhost URL 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 than localhost, 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_BASE overrides the endpoint for testing.

Fixed

  • 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_data clears a tray when it receives a partial {id, state} update whose state != 11 — the rule that lets 4-slot AMS units (e.g. H2D) report an emptied slot with just {id, state} and no tray_type (#784), where 11 = loaded. But an AMS-HT (single-tray high-temp dry box, unit id ≥ 128) reports its loaded tray as state=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's tray_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. The state != 11 → empty heuristic 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 explicit tray_type == "" update and the tray_exist_bits cleanup, both unchanged, and regular AMS behavior (id < 128) is untouched. Covered by tests for the HT tray surviving a state=9 partial, the HT still clearing on an explicit empty, and the existing regular-AMS state=9/10/11 cases.
  • 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_file twice ~3 minutes apart with no completion between, and the printer answering 0500_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 published project_file with no check on the printer's gcode_state. The scheduler does gate dispatch on an idle check, but that check treats FINISH as idle, and a printer can keep reporting FINISH for tens of seconds after it accepted a project_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 publish project_file when 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 / FAILED remain 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 inflight project_file to replay.
  • Three more idle-in-transaction / thundering-herd paths surfaced by continued farm testing (#2572, reporter Jostxxl) — On origin/dev with 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_selected opens one async_session per 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 first SELECTs. One correlated session's last statement was a settings SELECT at 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_print now commits right before the FTP delete/upload, and _preheat_and_soak commits after its read phase and before the up-to-15-minute soak wait (both loops touch only printer_manager state and asyncio.sleep, no DB). expire_on_commit=False keeps the loaded rows readable; the status writes afterward (upload-failure path and the pending→printing CAS) transparently open a fresh transaction. (b) /cloud/filament-info held its request session across sequential Bambu Cloud round-trips and single-flighted nothing. The route took its session via Depends(get_db) (held for the whole request), read the stored token, then looped over the uncached ids issuing one external get_setting_detail HTTP 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}/cover had 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 adds pool_use_lifo (PostgreSQL default on, DB_POOL_USE_LIFO override, shown in /system/db-pool) so a bursty farm keeps a small hot connection set busy and lets excess overflow connections age out via pool_recycle instead 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 in ams_mapping; the scheduler still forwarded the stale use_ams=false. The print-command builder only ever forced use_ams off (the all-external case) and never back on, so use_ams=false shipped alongside ams_mapping=[<real tray>] → external spool → abort. Fix. For single-nozzle printers the resolved mapping is now authoritative: a real AMS tray (0-253) forces use_ams=true; an explicit external selection (254/255) still forces it false; an unresolved -1 mapping 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, where use_ams encodes 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 VP false+real-tray promotion, padded mappings, all-external staying off, unresolved -1 staying 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 in status="printing" (missed completions, disconnects, restarts) by synthesising an aborted on_print_complete. That wrote a PrintLogEntry whose duration was completed_at - started_at — but for a reconciled archive the real end time is unknown: the print stopped somewhere during the disconnect, and completed_at is 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 recomputed completed_at - started_at whenever the stored duration was falsy, so storing NULL wouldn't have helped. Reconciled completions now log an explicit duration_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 truthful failure_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 to aborted, 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 .3mf didn'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 same cap_tls_v1_2 profile as the P2S/X2D (with its O1C/O1C2 SSDP 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 carried ams_mapping=[-1], and Bambuddy turned that into a print with no AMS. Two faults combined. A -1 was read as "external spool." The command builder's rule for "all slots are external, so drop use_ams" tested t < 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 >= 254 may now force use_ams=False; -1 never 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--1 one while the trays are unknown — so the scheduler resolves it at dispatch. A genuine no-match with trays present still serializes -1 and surfaces the mismatch as before. Tests. Backend: the command builder keeps use_ams=true for [-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=False behaviour was corrected to the fixed contract. Frontend: the mapping hook returns undefined while status is loading, resolves to the AMS tray once it arrives (type-only match with strict colour off), and still emits -1 for 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 retry and expire are required. Pushover mandates those two parameters for Emergency alerts — retry is how often it re-alerts (minimum 30 s) and expire is 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 in M211 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 touches M211, 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 broadcasts spool_assignment_changed for 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 out from permission_checker / is_jti_revoked / is_auth_enabled, and every one of the 30 stuck sessions was idle in transaction with the same last statement — the auth_enabled settings SELECT. Three things had regressed on dev after 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 on pool_size=10 + max_overflow=20 (30 connections total) with no way to raise it; the DB_POOL_SIZE / DB_MAX_OVERFLOW / DB_POOL_TIMEOUT / DB_POOL_RECYCLE env knobs and the GET /api/v1/system/db-pool gauge were gone. The PostgreSQL default is again 20 + 80 (100) with pool_pre_ping and a 1800s pool_recycle, all env-overridable, and /system/db-pool is 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 at 20 + 200. (b) Every protected request re-queried auth_enabled from the database. That per-request round-trip — the exact SELECT seen 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-jti check opened a second async_session on 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_revoked now 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 via Depends(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=False keeps the loaded printer.* 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_async and delete_file_async wrapped their worker in a bare run_in_executor with no asyncio.wait_for (unlike list_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 with asyncio.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_email ran smtplib synchronously 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, and quit() moved into a finally so 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_connections looped over every active printer and awaited each connection serially, and each connect_printer ends in a fixed one-second settle wait. The MQTT connect itself is non-blocking — BambuMQTTClient.connect() only calls connect_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 with asyncio.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 serial await allowed, 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 transaction sessions lasting minutes, and traced one to on_print_start: its last statement was SELECT print_archives…, immediately followed in the log by the printer's own on_print_startTrying 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 read SELECTs 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, and expire_on_commit=False keeps the already-loaded printer.* 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}/cover took its printer row via Depends(get_db), and get_db is a yield dependency — 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 one SELECT; everything after reads already-loaded printer.* 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=False keeps the columns readable), mirroring the camera-stream fix. Pinned by a regression test that fails if a get_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 a plate_id the 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-parsed Metadata/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 transaction for 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_retries polls 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 sat idle in transaction for 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/stream MJPEG endpoint took its printer row via Depends(get_db), but get_db is a yield dependency: 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 connection idle 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=False keeps the already-loaded columns readable). Pinned by a regression test that fails if a get_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 connections idle in transaction; unrelated API requests then waited out the 30-second pool timeout or failed in the auth middleware, and an unauthenticated /api/v1/printers probe took ~25s to return 401. Three things fed the pressure: the pool was fixed and not configurable; every authenticated request re-queried auth_enabled from 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 via DB_POOL_SIZE / DB_MAX_OVERFLOW / DB_POOL_TIMEOUT / DB_POOL_RECYCLE env vars and raises the PostgreSQL default to 20 + 80 (100 total) with pool_pre_ping and a 1800s pool_recycle; (b) caches the auth_enabled probe 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 a GET /api/v1/system/db-pool diagnostic exposing the resolved config plus live checked_out / checked_in / overflow gauges (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 required max_connections headroom.
  • 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/stream request 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/stop and the new /camera/stream concurrently, 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 show Chamber 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 with 2 against 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 without token= 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/stop consults 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 the error field 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() stamped token_expiry = now + 30 days every time a stored token was loaded from the database — re-derived from the current moment, for a token of entirely unknown age — and is_authenticated was "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/status answered true for 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 the refreshToken it 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/status asks 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 with urllib rather than httpx, on purpose — S3 signs the exact query-string bytes, and httpx re-encodes them into a SignatureDoesNotMatch. What that swap quietly also changed was the trust store. httpx — every other network call in Bambuddy, including the api.bambulab.com calls that succeed immediately before this one — verifies against the bundled certifi CA bundle. urllib verifies against the operating system's store, and on Windows the two disagree: Python's ssl.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 complete ca-certificates bundle, 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. certifi is 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 .3mf took 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-pending row being dispatched twice. FTP work also moves off asyncio's shared default executor onto its own pool: that executor is sized min(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_id advances) but never actually begins, the start-watchdog waits 270 seconds, reverts the queue item to pending, 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 column LibraryFile does 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_type is 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's material — 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 #000000 spool 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's filament_type is 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's filament.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 leaves PLA,PLA untouched; 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.log and 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 is null as 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 <= 1 hid 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 an ams_mapping anyway. 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 posts ams_mapping: [0, 1] for both plates. (It reproduces only with a realistic React Query cache — the test harness's gcTime: 0 evicts 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 in computeAmsMapping) 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 on PLA (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. A force_color_match entry 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's required_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. ProjectModal is shared: the detail page hands it a full project, the list hands it a list item. The list endpoint's payload never carried tags, due_date or priority, so from the list the dialog seeded those three fields from undefined and 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 on ProjectListResponse and on ProjectListItem, 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 to normal. 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 as undefined, 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 stored high instead of submitting its default, and clearing the tags field sends null. The templates list was missing target_parts_count too, 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 is gid=backup,dir_mode=0775, the service user is in backup, 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 ships ProtectSystem=strict, which mounts the entire filesystem read-only inside the service's own mount namespace and carves back out only ReadWritePaths=<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.service wholesale, so any ReadWritePaths an 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 next compose 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 .service in 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 actual mount -o ro tmpfs 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 SIGKILLCMD ["sh", "-c", "uvicorn ..."] left the shell as PID 1 with uvicorn as its child, and dash does not forward signals. So docker stop SIGTERMed 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 after Uvicorn 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, no engine.dispose(). Not "when a camera is streaming" — always, on every docker stop, docker restart, compose down and image update. The fix is one word: CMD ["sh", "-c", "exec uvicorn ..."]. With exec, uvicorn is PID 1 and receives the signal. Verified on a rebuilt image: PID 1 is now uvicorn, docker stop completes in 1 second with exit code 0, and the log shows Shutting downWAL checkpoint completedApplication shutdown complete.
  • systemctl restart could hang for 90 seconds and end in SIGKILL — with a camera tile open, stopping Bambuddy would sit at Waiting for connections to close. until systemd gave up and killed it. Uvicorn's timeout_graceful_shutdown defaults to None, i.e. wait forever for in-flight requests, and an MJPEG camera stream is a response that never completes — httptools's connection shutdown() only flips keep_alive = False on 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 shipped deploy/bambuddy.service, the systemd unit and launchd plist emitted by install/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 on CancelledError — a path they already handled. TimeoutStopSec is raised to 30s on the systemd units as a backstop rather than the mechanism, and stop_grace_period: 30s added 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 default AppStopMethodConsole is 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 Dockerfile execs, 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, power and today. It never set yesterday or total at all, so SmartPlugEnergy defaulted 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 is aenergy.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 under today. 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. With total never 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's energy_tracking_mode is total, 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_path still means "energy used today", and a new rest_energy_total_path means "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 zeroes aenergy.total) reports nothing rather than a negative. Local midnight, not UTC. With TZ=Europe/Berlin a 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. Every DateTime column 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 raises DataError: invalid input for query argument. So on Postgres every energy-snapshot capture raised (silently, inside the loop's except), leaving the snapshot table empty and the date-filtered energy stat permanently zero, and every plug status poll raised on last_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 shared utcnow_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 own Switch.GetStatus payload (the lifetime counter lands in total and not in today; 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

  • 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) and tray_pre (the slot that ran out) right next to tray_now, but Bambuddy parsed tray_now only 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_pre are 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) as expected_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 on is_active would 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 behind PRINTERS_READ, so a kiosk got a 401 and an empty wall. The obvious fix — let the existing camera_stream token through to GET /printers — is the wrong one: that response carries every printer's serial_number and ip_address even 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 at GET /api/v1/camwall/printers that 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 new camwall token scope alongside camera_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 because camera_stream tokens 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 at compact even if the URL asks for full. 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; a camera_stream token refused by the feed (the assertion the separate scope exists for); a camwall token 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=full refused, 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_status cached, mc_percent and 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 when gcode_state is 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 holds gcode_state=FINISH and passes the real mc_print_stage, mc_percent, mc_remaining_time, stg, stg_cur, layer_num and total_layer_num through underneath it, at the existing 1 Hz push. Send stays enabled in every mode and #1558 does not come back. What it costs. The slicer's Pause / Resume / Stop buttons stay greyed for a server-mode VP, since it now reports a finished job rather than a running one — they were greyed before this change too, so nothing is lost; drive the print from Bambuddy, or use Proxy Mode, where the slicer talks to the printer directly and they work. print_error is never mirrored either: a fault on the printer would raise a modal error dialog in the slicer for a machine that did not throw it, and the printer's own card already reports it. The upload handshake wins. Mirroring is suppressed while a job is being handed over (gcode_state=PREPARE) and for five seconds after the last upload transition — the slicer only releases its in-flight-job lock when it sees FINISH carrying the subtask_name it just uploaded, so swapping in the printer's filename mid-handshake would wedge the send modal at "Downloading". Once settled, the report switches to the job that is actually on the bed, which is the one the user wants to watch. Tests. 8 cases: progress mirrors while the target prints; the mirrored state is never one the slicer reads as busy (parametrised over RUNNING and PAUSE — this is the assertion that keeps #1558 fixed); progress stays zeroed while the target is idle, while an upload is in flight, and inside the settle window, where the slicer's own filename is still echoed back at it; mirroring resumes once the handshake has settled; print_error is suppressed while the rest still mirrors. Verified by mutation — forcing the mirror off fails five of the eight. Scope. Backend only, non-proxy VPs with a target printer bound. No DB migration, no schema change, no new setting, no new permission, no i18n change.
  • Slicer Pipelines — multi-copy batches, class targeting, fanout strategies, runs dashboard, retry-failed, live WS updates (#1425 PR C — completes the v3 design) — The PR A/B drop turned slice-modal preset bundles into one-click dispatches with a pinned target printer. PR C closes the original issue with full production-batch semantics: an operator picks a saved pipeline, types in a number of copies, and Bambuddy slices once and distributes the prints across a fleet according to the pipeline's chosen fanout strategy. The runs dashboard surfaces every active and historical run with filters, per-row expandable per-copy status, cancel-in-flight, and retry-failed-copies. WebSocket pushes keep the dashboard and the in-Settings "Last run" chip live without polling. Backend. PipelineRunCreateRequest.copies (Pydantic ge=1, le=1000) replaces the implicit 1 from PR B; the orchestration loop creates one PipelineJob row per copy. SlicerPipelineUpdate accepts target_kind (specific_printer / printer_class), target_model_class (Bambu model code: A1 / A1 Mini / P1P / P1S / P2S / X1 / X1C / X1E / H2D / H2D Pro / H2C / X2D), and fanout_strategy (max_parallel / round_robin / fill_one_first). A new pipeline_max_copies setting (default 50, Pydantic ge=1, le=1000) gates the copies input in the Run-with-pipeline modal and is enforced again at POST /run time so an API caller can't bypass the cap. PR C also adds PipelineRun.parent_run_id (nullable FK to itself, ON DELETE SET NULL) so retry runs link back to the run whose failed copies they re-attempt. Eligibility for class targeting. The matcher in services/pipeline_eligibility.py now branches on pipeline.target_kind: the specific-printer path is unchanged (PR B parity), the new class-targeting path enumerates every Printer whose model matches pipeline.target_model_class, runs the per-printer slot-by-slot check for each via a status_lookup closure that the route handler hands in (so the matcher stays pure-ish for unit tests), and returns a top-level printer_reports: list[PerPrinterReport] with ok derived as any across the candidates. New issue kinds: no_class_matches (the install has zero printers in the chosen model class) and class_not_set (target_kind is printer_class but no model was picked). The lenient-policy story is the same — operators can Run anyway past blocking issues, and PipelineRun.eligibility_overridden is set so the audit trail shows it. Orchestration + fanout. A new _pick_assignments(pipeline, copies) helper returns [(printer_id_or_None, target_model_or_None), …] of length copies per the picked strategy. max_parallel sets target_model=pipeline.target_model_class on every queue item and leaves printer_id=None — the existing print scheduler's model-based dispatch picks any idle matching printer per item; the result is that multiple printers grab work in parallel without any new scheduler code. round_robin enumerates eligible printers (is_active=True, model matches) ordered by id and assigns copy i to eligible[i % len(eligible)] — each item gets a fixed printer_id, the wear distributes evenly. fill_one_first pins every copy to eligible[0] so a one-printer fleet stays one-printer even when others come online mid-run; the documented trade-off is that a printer failure freezes the queue at that printer until the operator intervenes. All three flows reuse the same slice-once path; the slice runs through slice_dispatch.enqueue exactly as PR B did so the persistent progress toast renders end-to-end for batches just like single-copy runs. Routes. GET /pipeline-runs?limit&offset&pipeline_id&status is the dashboard endpoint — newest-first, paginated, filterable by pipeline and persisted snapshot status. POST /pipeline-runs/{id}/retry-failed counts the parent's failed-or-cancelled jobs at the live (queue-entry-aware) status level, builds a fresh PipelineRunCreateRequest with copies=that count and force=True (operator already accepted eligibility on the parent), routes it through the existing run_pipeline handler, and stamps parent_run_id on the result. Returns 400 when the parent's source or pipeline was deleted, or when there are no failed copies to retry. POST /pipeline-runs/{id}/cancel extends PR B's cancel to cascade across N queue entries — only the ones still in pending / queued are touched so in-flight prints continue on the printer (operator must Stop on the machine). WebSocket. New pipeline_run_updated event type carries the full materialised PipelineRunResponse and fires on every state transition (queued → slicing → dispatching → in_progress → completed | failed | partial_failure | cancelled). Per-user routing via ws_manager.broadcast_to_user(run.created_by, …) so each operator sees their own runs without cross-user noise; auth-disabled installs broadcast to all connections (PR B's pattern). The frontend's useWebSocket switch handles it by invalidating both ['pipeline-runs-all'] (the dashboard) and ['pipeline-runs', pipeline_id] (the per-pipeline "Last run" chip in Settings). The dashboard still polls every 15 s as a belt-and-suspenders for missed messages. Run status roll-up. A new _roll_up_run_status function computes the run-level status from the per-job statuses at read time: all-completed → completed, any in-flight → in_progress, some completed + some failed → the new partial_failure status (this is what gets the Retry-failed button), all failed → failed. The persisted snapshot is still written on terminal transitions for the dashboard's status filter to remain useful. copies_completed / _failed / _cancelled / _in_progress counts ride on the response so per-row "1/3 · 2 failed" summaries don't need a second query. Frontend. The Settings → Workflow → Pipelines pipeline editor grows three new controls in the edit form: a radio for target_kind (Specific printer / Printer class), a model-class picker filtered to the models present on at least one installed Printer row (so users can't pick "H2C" if they only have X1Cs), and a fanout-strategy radio with the three options labelled with their use cases. The read-only row reflects class targeting with a "X1C · Round robin" line in place of the printer name. RunWithPipelineModal grows a number input for copies bounded by settings.pipeline_max_copies, accepts class-targeted pipelines (the "Apply pipeline" button is enabled when the pipeline has either a pinned printer OR a class target), and the pipeline-list row shows "Any X1C" instead of a printer name for class pipelines. The "Run pipeline" Setting → Workflow → Queue & Dispatch sub-tab gets a new "Slicer Pipeline limits" card with the max-copies input (bounded 1–1000 client-side, server enforces the same). New dashboard page at /pipelines/runs (sidebar entry under Print Queue, gated on pipelines:read). Lists every run across every pipeline with two dropdown filters (pipeline + persisted snapshot status) and pagination at 25 per page. Each row shows pipeline name, status chip (partial_failure is amber), source file, created-at timestamp, and "{completed}/{copies}" + "{failed} failed" rollup. Click the chevron to expand a per-copy panel listing each PipelineJob's assigned printer + status + error message. In-flight runs get a Cancel button; partial-failure / failed runs get a Retry-failed button. i18n. ~43 new keys across nav.pipelineRuns, pipelineRuns.* (title / filters / pagination / job-status chips / toasts), settings.pipelines.field.* (targetKind / fanout / class), settings.pipelines.runs.status.partial_failure, settings.pipelineLimits.*, library.runWithPipeline.* (copies / copiesHint / classTarget / issue.noClassMatches / issue.classNotSet), and common.previous / common.next — translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5516 leaves per locale, no English fallback. Copies / {{n}} copies / max {{n}} added to the French + Italian cognate allowlists where they're genuine. Tests. Six new backend cases in test_pipeline_runs_api.py covering copies-cap rejection (schema gate at 1000), 3-copy run creates 3 jobs with sequential copy_index, class eligibility with two X1C candidates returns a 2-entry printer_reports array, class eligibility with no matching printers in install returns no_class_matches, dashboard list endpoint with pagination + status filter, retry-failed correctly counts failed jobs from a partial-failure parent and stamps parent_run_id. Plus the existing 16 PR A/B cases were lightly updated where class_not_set is now a valid no-target signal alongside printer_not_set. Five new frontend cases in PipelineRunsPage.test.tsx pin the dashboard's empty state, list rendering, Cancel button on in-flight runs, Retry-failed button on partial-failure runs, and per-row expand to show jobs. Three updated frontend cases (RunWithPipelineModal.test.tsx) assert the new four-arg signature on runPipeline (pipelineId, source, force, copies). One updated SettingsPage.test.tsx sidebar-order test reflects the new pipelineRuns nav entry between queue and projects. Suites. pytest -n 30 backend/tests/ 6539/6539 green; npx vitest run 2284/2284 green (173 files); npm run build clean; python -m ruff check backend/ clean; node scripts/check-i18n-parity.mjs clean. Scope. PR C closes the v3 design — no further pipeline PRs are queued. The existing print scheduler's model-based dispatch (PrintQueueItem.target_model + target_location + required_filament_types) is the only thing that makes class targeting actually distribute work; PR C just plugs into it. The fill_one_first strategy's "one printer fails, queue stalls" trade-off is documented in the editor's option-row hover-hint and in the orchestrator code comment — it's the correct behaviour for "I want one printer to finish a batch end-to-end" and the wrong behaviour for "I want resilience"; the right strategy for resilience is max_parallel. Cross-printer-class pipelines (e.g. one pipeline targeting "any X1C OR P1S") remain out of scope — make two pipelines, one per class.
  • Slicer Pipelines — Archive entry point + progress toast for pipeline-driven slicing (#1425 PR B follow-up) — Two real gaps from the PR B drop. (1) The Run-with-pipeline button only existed in the file manager — operators who keep their working files in archives had to copy them out to the library to use a pipeline. (2) Triggering a slice via a pipeline produced a silent multi-second-to-minute wait — the manual SliceModal flow has the sticky Slicing X — Generating G-code 75% persistent toast, the pipeline path went through asyncio.create_task directly and never registered with SliceJobTracker. Fix. (1) POST /slicer-pipelines/{id}/check-eligibility and POST /slicer-pipelines/{id}/run now accept source_archive_id as an alternative to source_library_file_id (XOR — Pydantic validator rejects both-set and neither-set), and the eligibility-check and orchestration paths branch via _resolve_source which reads archive.source_3mf_path with a fallback to archive.file_path. PipelineRun.source_archive_id is a new nullable FK column (Postgres + SQLite ALTER TABLE in run_migrations — idempotent via _safe_execute). PipelineRunResponse echoes the field. ArchiveCard's context menu picks up a Run with pipeline item alongside the existing Slice action (only on source archives — gcode archives already have Print + Open in BambuStudio), gated on useSlicerApi + pipelines:run. Path-safety: Path(base_dir) / archive.source_3mf_path carries a SEC-PATH-OK marker citing the upload-time validator at _resolve_source_3mf_path (same comment style as routes/archives.py:3955); the LibraryFile.file_path site gets the same treatment. (2) The pipeline orchestrator is now the run callable of a slice_dispatch.enqueue call — the same dispatcher the manual SliceModal flow uses — instead of a bare asyncio.create_task. The SliceJob's lifecycle (pending → running → completed/failed) drives the existing progress toast end to end: same persistent toast, same Generating G-code 75% weave from the sidecar's --pipe channel, same auto-replace with a transient success/error toast on terminal. PipelineRun.slice_job_id is set on the run row before the route returns 202, so the frontend can call useSliceJobTracker().trackJob(slice_job_id, source.kind, source.filename) from RunWithPipelineModal's runMutation.onSuccess — same one-call surface that SliceModal's slice mutation already uses. (3) RunWithPipelineModal's source prop is now {kind: 'libraryFile' | 'archive', id, filename} (mirrors SliceModal.SliceSource); api.checkPipelineEligibility + api.runPipeline take a discriminated-union source argument and route to the right backend field. PipelineRun TS type grows source_archive_id. Tests. Three new backend cases in test_pipeline_runs_api.py — archive-source happy path (creates a PrintArchive row + on-disk file, posts with source_archive_id, verifies the response carries source_archive_id + slice_job_id from a stubbed slice_dispatch.enqueue), XOR rejection both-set, XOR rejection neither-set. The existing three run/cancel cases were updated to patch backend.app.services.slice_dispatch.slice_dispatch.enqueue (the new mock target) instead of the removed _run_pipeline_orchestration helper, and the run-happy-path now asserts slice_job_id == 9001 arrives on the response. One new frontend case in RunWithPipelineModal.test.tsx pins the archive flow end to end (checkPipelineEligibility called with {kind: 'archive', id: 7}, then runPipeline with the same). The existing fast/slow path tests were updated to wrap in SliceJobTrackerProvider (the new useSliceJobTracker hook requires it) and to assert the new discriminated-union source argument. Suites. pytest -n 30 backend/tests/ 6533/6533 green; npx vitest run 2279/2279 green (172 files); npm run build clean; python -m ruff check backend/ clean; node scripts/check-i18n-parity.mjs clean. Scope. No new i18n keys — both fixes reuse the existing PR B keys. No new permission. The archive flow only branches at the source-resolution layer; everything downstream (eligibility, slice, queue dispatch) is the same code path the library flow uses. PR C scope (multi-copy + class targeting + fanout) is unchanged.
  • Slicer Pipelines — Run a pipeline on a file with one click (#1425 PR B) — PR A landed the bundle (save & apply preset slots in the SliceModal). PR B turns that bundle into an actual one-click dispatcher: file-manager rows now carry a Run with pipeline ▾ button that slices the source through the pipeline's pinned printer/process/filament/bed-type combo and enqueues the print on the pipeline's pinned target printer. Scope. Single-target dispatch — target_kind='specific_printer' only. Multi-copy batch + class targeting + fanout strategies are PR C; the schema columns are already in place from PR A so PR C is code-only. Backend. Two new SQLAlchemy models — PipelineRun (one row per Run-pipeline click, carries the slice_job + sliced_library_file ids + snapshot status) and PipelineJob (one row per copy; PR B always 1, PR C variable). Soft-link to slicer_pipelines via ondelete='SET NULL' so run history survives a pipeline delete; same for source_library_file. status on the run is a persisted snapshot that gets terminal transitions written (slice failure, cancel, completion); in-flight reads roll up the live state of the linked queue entry via _compute_run_status — that keeps the status accurate (pending → printing → completed) without a background watcher writing on every queue tick. Eligibility matcher at services/pipeline_eligibility.py — given a pipeline + the live PrinterState from printer_manager.get_status, returns a structured report with typed issues: printer_not_set, printer_not_found, printer_disabled (from Printer.is_active shipped with #1476), printer_offline, filament_type_mismatch, filament_color_mismatch, ams_slot_missing, filament_unverified (cloud/standard tier presets can't be statically read here; surface as info, not a block). Canonical filament-type map mirrors print_scheduler._canonical_filament_type so PLA Basic / PLA Matte / etc. all collapse to PLA for the type comparison; colour normalises to six-hex-digit lowercase. Eligibility is lenient with confirmation — the report drives the frontend confirmation modal, but the user can Run anyway (sets eligibility_overridden=True on the run row so the audit trail shows which runs bypassed pre-flight). Routes. Two new routers — pipeline_run_create_router mounted under /slicer-pipelines (POST /{id}/check-eligibility, POST /{id}/run, GET /{id}/runs?limit=N) and pipeline_run_router at /pipeline-runs (GET /{id}, POST /{id}/cancel). POST /run returns 202 with the run shape; orchestration happens in a fire-and-forget asyncio.create_task that opens its own DB session (the request's session is closed by the time it runs) and walks: status='slicing' → slice_and_persist with the pipeline's SliceRequest → on success status='dispatching' + insert PrintQueueItem with printer_id=target_printer_id, library_file_id=sliced_library_file_id. The existing scheduler picks the queue entry up on its next tick. POST /run with eligibility issues and no force returns 409 with the report inside detail so the frontend can render the same confirmation modal it would for an explicit pre-flight; force=true bypasses the 409 but a missing target_printer_id still 400s (defence in depth — the UI can't enqueue the print without a target). POST /cancel is idempotent on terminal states and cascades to the linked queue entry when its status is still pending / queued (in-flight prints continue — operator must Stop on the printer itself). SlicerPipeline.target_kind / target_printer_id become writable via PUT /slicer-pipelines/{id} — the schema accepts both fields, the route treats target_printer_id=0 as "clear" (the empty-<option> HTML coercion) and a positive value as a literal FK. Frontend. SlicerPipelinesPanel in Settings → Workflow → Pipelines extends its edit form with a target-printer <select> (populated from api.getPrinters()); pipelines without a target render an amber "Set a target printer to run this" hint in the row + a "Set a target printer before running this pipeline" warning at the bottom. Last-run summary appears inline per row — small Last run: completed · 27/06/2026, 14:23 line driven by GET /slicer-pipelines/{id}/runs?limit=1 with a 15 s refetchInterval so the chip ticks while a run is in flight. RunStatusBadge colour-codes the seven states. New component RunWithPipelineModal at components/RunWithPipelineModal.tsx — two-step dialog: step 1 lists the user's pipelines (each row shows the pinned target printer; pipelines without a target are disabled with a No target printer set hint), step 2 is the eligibility confirmation. Fast path: ok=true skips step 2 entirely and fires the run straight from the pipeline pick. Slow path: shows per-issue text via the IssueText mapper — eg. Filament slot 1: expected PLA, AMS has PETG for filament_type_mismatch, AMS slot 2 not available on this printer for ams_slot_missing — then Run anyway posts with force=true. FileManagerPage integration: FileCard's action menu picks up a Run with pipeline entry (gated on the new pipelines:run permission); list-view rows get a matching inline Play-icon button so list users have the same entry point as card users. Both flow into the same setRunPipelineFile(file) state which renders the modal. The action is only offered on slice-eligible files (3MF / STL / STEP) and only when use_slicer_api is on — matches the existing Slice button gating, since a non-slice-eligible file can't reach the slice step in any case. Frontend types: client.ts grows PipelineEligibilityReport, PipelineRun, PipelineJob, PipelineRunListResponse, plus six new api.* methods (checkPipelineEligibility, runPipeline, listPipelineRuns, getPipelineRun, cancelPipelineRun, and the updated updateSlicerPipeline which now accepts target_kind + target_printer_id). The Permission union also gets pipelines:read | pipelines:write | pipelines:run — these were on the backend Permission enum from PR A but had been missed in the frontend union (caught when TS rejected hasPermission('pipelines:run')). i18n. ~36 new keys across library.runWithPipeline.* (modal title / confirm / source-hint / pipeline-hint / target-hint / Run-anyway / 8 issue-kind strings / 2 toast / empty-state / no-target hint) and settings.pipelines.field.targetPrinter / field.noTarget / noTargetHint / noTargetWarning / runs.lastRun + seven runs.status.* strings — translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5473 leaves per locale, no English fallback. The string slicing was added to IT_COGNATES (genuine cognate — same word in Italian). Tests. 13 new backend integration cases in test_pipeline_runs_api.py covering PUT target write + clear-via-0 + check-eligibility (printer_not_set / printer_disabled cascade with offline / fully-clear AMS-match) + run flow (409 on issues+!force / 400 on force+!target / 202 on clean path with creation of run+job) + list/get 404s + cancel (404 / marks queued / idempotent on terminal). Slicing itself is stubbed via patch(..._run_pipeline_orchestration) so CI runs without a live sidecar. 4 new vitest cases in RunWithPipelineModal.test.tsx pin the modal's two-step flow: empty state, disabled pipeline-without-target, fast-path (issues empty → modal closes immediately after runPipeline(..., false)), slow-path (issues shown → Run anyway posts with force=true). Suites. pytest -n 30 backend/tests/ 6530/6530 green; npx vitest run 2278/2278 green (172 files); npm run build clean; python -m ruff check backend/ clean; node scripts/check-i18n-parity.mjs clean. What's out of scope for PR B. Multi-copy (copies > 1), class targeting (target_kind='printer_class'), fanout strategies, the Pipeline Runs dashboard — all PR C. Painted multi-filament 3MFs still hit the upstream OrcaSlicer CLI gate (OrcaSlicer/OrcaSlicer#13774); the slice step inside the pipeline run fails the same way the standalone slice route does, the run rolls up to status='failed' with the slicer's error string in error_message. The print queue's existing AMS / filament check + the printer-side error path remain authoritative for what actually happens at the machine — pipeline eligibility is a pre-flight, not a hard guard.
  • **Slicer Pipelines — save & reuse a preset bundle in one cli

Changelog truncated — see the full CHANGELOG.md for the complete list.

Don't miss a new bambuddy release

NewReleases is sending notifications on new releases.