github maziggy/bambuddy v0.2.5b2-daily.20260714
Daily Beta Build v0.2.5b2-daily.20260714

pre-release4 hours ago

Note

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

Fixed

  • 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.
  • 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

  • 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 click (#1425 PR A, requested by TheUltimateC0der) — Top feature in the first sponsor vote. The SliceModal forces the user to pick four slots every time: printer / process / filament(s) / bed type. For fleet production that's tedious and error-prone — operators want a named "Production PLA" bundle they can apply with one click on every file and every printer. PR A scope. Definitions only. The new model slicer_pipelines materialises the bundle plus future-PR columns (target_kind, target_printer_id, target_model_class, fanout_strategy) so PR B (single-target dispatch) and PR C (multi-copy batch with capability-matched fanout) are code-only, not migrations. The bundle is independently useful in PR A as an ergonomic improvement: pipelines are picked from the SliceModal, applied to the four slots, then sliced through the existing flow. No new dispatch behaviour yet. Backend. Model SlicerPipeline (models/slicer_pipeline.py), Pydantic schemas SlicerPipelineCreate / Update / Response reusing the existing PresetRef shape from schemas/slicer.py, CRUD routes at /api/v1/slicer-pipelines/ (GET list, POST create, GET/PUT/DELETE by id). Soft-delete via is_deleted so PR B+ run history can still resolve pipeline metadata after the operator removes one. Listed newest-first by id DESC (more reliable than created_at under back-to-back inserts whose DateTime precision can tie). Routes use explicit await db.commit() after the mutation (matches the routes/library.py pattern) so the response shape returns the committed row. Permissions. Three new Permission values: PIPELINES_READ, PIPELINES_WRITE, PIPELINES_RUN. PR A only consumes the first two; RUN is defined now so PR C doesn't need to backfill. Administrators and Operators get all three; Viewers get PIPELINES_READ. A backfill block in seed_default_groups() adds them to existing groups on upgrade (mirrors the library:purge / archives:purge pattern from earlier). All three are added to _APIKEY_DENIED_PERMISSIONS so they fail closed for any API-key surface — PR B / PR C may move PIPELINES_RUN onto can_queue once the dispatch lands. Frontend. Settings → Workflow tab is split into two sub-tabs mirroring the Authentication tab's pattern: Queue & Dispatch (the existing Workflow content) and Pipelines (the new manager). The Workflow sidebar entry stays single — no expandable submenu — and the sub-tab choice is reflected in the URL (?tab=queue&sub=pipelines) for deep-linking. SlicerPipelinesPanel lists saved pipelines with inline rename, soft-delete, and a stale-preset warning when a referenced preset no longer resolves against the unified-presets listing (e.g. an orca_cloud preset deleted in OrcaSlicer; the pipeline still saves, the warning prompts a re-save from the SliceModal). Full pipeline creation lives in the SliceModal rather than Settings — the user has already done the four-slot work there. The modal grows an Apply pipeline ▾ dropdown plus a Save as pipeline button above the existing preset dropdowns. Apply fills all four slot states (printerPreset, processPreset, bedType, filamentPresets[]); the filament list right-pads from current state so a pipeline with fewer entries than the current source's slot count keeps the existing tail (lets the same pipeline apply across single-color and multi-color files). Save captures the four-slot picks under an inline-named pipeline. Stale-preset warning shows on the Settings list, not blocking apply, so an old pipeline with a one-deleted-preset can still be re-applied and re-saved with the new pick. i18n. ~30 new keys across settings.pipelines.* and slice.pipelines.* plus settings.tabs.queueDispatch / queuePipelines, translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5437 leaves per locale, no English fallback. Pipeline / Pipelines / Filament {{n}} added to IDENTICAL_TO_EN_ALLOWED for the locales where they're genuine cognates (de / es / fr / it / pt-BR / tr). Tests. Backend: 11 integration cases in test_slicer_pipelines_api.py covering empty list, create + round-trip, get-by-id, partial PUT preserves untouched fields, filament list replaces wholesale, soft-delete hides from list + GET-by-id, 404s on missing, schema rejection of empty filament list + invalid PresetRef source, newest-first ordering. Frontend: 3 new SliceModal cases (apply-pipeline dropdown disabled-empty / apply-sets-state / save-as-pipeline-round-trip) plus 2 SettingsPage cases (sub-tab nav renders + Pipelines deep-link). Existing SliceModal tests adjusted via a presetSelects() helper that filters out the new Apply-pipeline combobox so historical selects[0] indexing into printer/process/filament remains stable. Suites. pytest -n 30 backend/tests/ 6517/6517 green; npx vitest run 2274/2274 green (171 files); npm run build clean; python -m ruff check backend/ clean; node scripts/check-i18n-parity.mjs clean. Scope. No new dispatch behaviour yet — pipelines are a preset-bundle convenience layer in PR A. PR B adds single-target dispatch (the target_kind='specific_printer' path), PR C adds multi-copy batch with capability matching + the three fanout strategies (max_parallel / fill_one_first / round_robin). The Run pipeline action mentioned in the original issue is PR B/C and intentionally not exposed in this drop. Painted multi-filament 3MFs still hit the upstream OrcaSlicer CLI gate (OrcaSlicer/OrcaSlicer#13774); the slice fails, the pipeline doesn't pre-validate.
  • Sticky upload-progress toast restored for scheduler-driven dispatch (#1625 follow-up)#1625 (Unify print dispatch through the scheduler) moved every print's FTP push to the printer into the server-side scheduler tick, which means the user's click no longer carries an XHR with progress events — the old browser-side upload modal had nothing to show because there was no browser-side upload anymore. Users only saw the queue item flip to "active" with no visibility into the multi-second to multi-minute FTP push + the H2D/H2D Pro 80–210 s project_file digestion window before the printer actually started extruding. Fix. The legacy bg-dispatch toast rendering from 0b43ac0d:frontend/src/contexts/ToastContext.tsx lines 510–650 is ported back in place verbatim — same DOM tree, same Tailwind classes, same formatFileSize bytes line, same uppercase status chip, same collapse chevron, same awaitingPrinter derivation, same auto-dismiss-when-all-terminal — only adapted to read from the four scheduler-side WS events introduced here instead of the legacy background-dispatch aggregate event. Materialization only on actual upload start. The toast appears when the FTP push to the printer starts (queue_item_uploading), NOT on POST /queue — a draft that emitted at queue-add time made the toast jump to "Dispatched" before any upload had happened. Four backend lifecycle WS events drive the rendering: queue_item_uploading (start of FTP, carries printer_name + total_bytes from file_path.stat().st_size), queue_item_upload_progress (throttled byte-level updates — first call always emits + emit when ≥200 ms elapsed OR ≥256 KB transferred since last emit, plus always emit at bytes_transferred >= total_bytes; this matches the legacy background_dispatch.py:614-615 gates 1:1 so the bar feels identical on small AND large files; a single shared _UploadProgressBridge instance bridges from the FTP executor thread back to the asyncio loop via run_coroutine_threadsafe), queue_item_acked (watchdog confirmed printer transitioned out of pre_state), queue_item_failed (any error, with a reason key the toast looks up as dispatchToast.failed.{reason} for upload-vs-start-command differentiation, generic fallback). No queue_item_dispatched event — the legacy bg-dispatch path kept status='processing' from upload start until printer ack, and the "Awaiting printer…" subtitle is derived purely from upload_progress_pct >= 99.9 (the legacy uploadDoneAwaitingPrinter trick at line 568-572). An explicit dispatched event would push the status chip out of PROCESSING prematurely — which is exactly what the first screenshot-iteration showed. Per-user routing. New ws_manager.broadcast_to_user(user_id, msg) filters connections by websocket.state.bambuddy_principal_user_id — resolved once at WS connect time via a select(User.id).where(User.username == principal) lookup so per-message routing is O(connections) not O(connections × DB). Auth-disabled installs route user_id=None to all connections, matching the legacy single-user toast behaviour. The watchdog success path receives created_by_id via a new kwarg so the static _watchdog_print_start method can still emit the acked event without re-fetching the queue item. Backend. ~110 LOC across 3 files: core/websocket.py (broadcast_to_user + four event helpers, bambuddy_principal_user_id filter on each connection), api/routes/websocket.py (principal username → User.id resolve at connect, stashed on websocket.state.bambuddy_principal_user_id), services/print_scheduler.py (_UploadProgressBridge thread-safe throttle class, queue_item_uploading emitted before FTP with printer.name, progress_callback= plumbed into both the with_ftp_retry and direct upload_file_async branches via **kwargs, queue_item_failed at the FTP-fail spot, watchdog success path emits acked on both Phase A and Phase B exits). Frontend. Rendering ported in place to contexts/ToastContext.tsx (dispatchData field on Toast, ingest useEffect mapping the four bambuddy:dispatch-toast event types to legacy DispatchToastJob shape, terminal-state auto-dismiss useEffect; legacy rendering block reused 1:1 minus the cancel button — BG dispatch's /background-dispatch/{id} DELETE doesn't exist in the scheduler model and adding it is out of scope). hooks/useWebSocket.ts forwards the four queue_item_* cases via window.dispatchEvent(new CustomEvent('bambuddy:dispatch-toast', { detail })), matching the existing plate-not-empty / unknown-tag patterns. i18n. 11 keys × 11 locales under dispatchToast (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW): untitled / startingPrints / progressSummary (header {{complete}}/{{total}} complete • Processing: {{processing}}Dispatched: X from the legacy summary was dropped because the scheduler has no pre-upload "dispatched" state) / expandDetails / collapseDetails / awaitingPrinter / status.{processing|completed|failed} / failed.{generic|upload_failed|start_command_failed} / dismiss. Locale parity check 5401 leaves per locale, no English fallback. Tests. Backend test_ws_broadcast_to_user.py pins the routing contract (filter by user_id, fan-out on None, payload shape with printer_name for uploading, server-side pct compute including divide-by-zero); test_upload_progress_bridge.py pins the throttle (first call always emits, 256 KB byte gate honoured even when time gate would skip, completion always emits, no-op on zero bytes, no-op when no loop). Frontend __tests__/contexts/DispatchToastContext.test.tsx pins the materialization-on-uploading invariant (stray progress / acked event before any uploading does NOT render — regression guard), the uploading → "Awaiting printer…" → acked lifecycle with status chip staying PROCESSING through the whole upload (regression guard for the screenshot-reported "Dispatched: 1 immediately" bug), 3.5 s auto-dismiss when terminal, concurrent jobs sharing one wrapper, collapse + dismiss buttons. Suites. pytest -n 30 backend/tests/unit/test_ws_broadcast_to_user.py backend/tests/unit/test_upload_progress_bridge.py backend/tests/integration/test_print_queue_api.py green; vitest run src/__tests__/contexts/ 49/49 green; ruff check backend/ clean; npm run build clean. Scope. No DB migration. No new permission. The bambuddy:dispatch-toast window event is internal to the frontend bundle, not a public hook — third-party plugins should not subscribe to it. The 0–30 s scheduler-tick pickup wait is unchanged; this fix only addresses visibility of what happens once the upload starts. Tiny test files that upload in a single FTP chunk will still jump straight to "Awaiting printer…" because the first-and-last progress callback is one and the same event — same edge as the legacy bg-dispatch behaviour on sub-256 KB files.
  • Cam Wall: don't kill shared streams when one viewer closes + offline tiles show OFF, not LIVE — Two small but load-bearing fixes against the new cam-wall view. (1) Offline tile chip. A disconnected printer (status.connected === false) was still assigned live mode by CameraWall.modeByPrinter — it consumed a Max live streams budget slot AND rendered the red LIVE chip on top of the WifiOff placeholder. The allocator now treats !connected like off-screen — assigns paused, leaves the live budget intact. The existing CameraTile rendering (WifiOff icon, dark Off chip) takes over automatically. Side effect: an 8-printer wall with 2 offline X1Cs no longer wastes 2 of the 4 default live slots on dead tiles. (2) Shared-broadcaster teardown. /api/v1/printers/{id}/camera/stop is the unmount cleanup for every camera consumer (CameraTile, EmbeddedCameraViewer, popup CameraPage). It used to unconditionally shutdown_broadcaster(f"printer-{id}") + kill every ffmpeg in _active_streams whose key starts with {printer_id}-. The fan-out broadcaster is shared across all viewers of the same printer, so closing the embedded viewer while the cam-wall tile of the same printer was visible force-killed the source the tile was pulling from — the tile's <img> errored out and showed No signal until the user navigated away. The broadcaster itself already has correct natural-shutdown semantics: each subscriber's HTTP teardown calls unsubscribe(queue), and when the count reaches 0 the broadcaster's own _grace_then_stop waits _GRACE_SECONDS (5 s) before tearing down — re-checking under the lock so a new subscriber rejoining cancels the shutdown. /camera/stop was just a fast-cleanup shortcut for the single-viewer case. Fix. New get_subscriber_count(key) accessor in camera_fanout.py exposes the broadcaster's subscriber_count (the private list-len already used internally). The /camera/stop route now reads get_subscriber_count(f"printer-{printer_id}") BEFORE the force-teardown; when ≥ 1 subscriber is still attached, it returns {"stopped": 0, "skipped": true} early and leaves the broadcaster + ffmpeg processes alone. The leaving viewer's HTTP teardown still runs the natural iter_subscriber.finally → unsubscribe path, so its subscription is correctly released; the broadcaster keeps serving the other viewer(s). Single-viewer close still hits the force-teardown path immediately (no subscribers remain at all). Cost: in the race where the leaving viewer's HTTP teardown has already propagated to the broadcaster at the moment its /camera/stop POST lands (count just dropped to 0), force-teardown still runs and we miss the optimization for a different actually-still-subscribed viewer — but the natural grace-shutdown bounds the worst case at 5 s of ffmpeg tail, not a stuck stream. Verified by inspection: this race only matters when subscriber_count transitions through 0 between the HTTP teardown and the POST, which requires both viewers' tabs to close in lockstep — practically unobservable. Tests. New test_stop_camera_stream_skips_shutdown_when_subscribers_remain in test_camera_api.py patches get_subscriber_count to return 2 and asserts /camera/stop returns {stopped: 0, skipped: true}, does NOT call shutdown_broadcaster, and does NOT terminate any _active_streams ffmpeg process. The existing 6 stop-route tests stay green because they don't pre-populate subscribers — get_subscriber_count returns 0, the early-return doesn't trigger, and the existing force-teardown still runs. Full test_camera_api.py 43/43 green. ruff check backend/ clean. Frontend npm run build clean. Scope. No API contract change — the existing {"stopped": int} shape is preserved, the new "skipped" field is additive. No new permission. No DB migration. No i18n change.
  • Cam Wall: per-tile print/printer status overlay — Cam-wall tiles now surface live printer state on top of the camera image instead of being a pure video grid. A new gear-menu toggle Status overlay switches between Off, Compact, and Full (default Full). Compact paints a colour-coded state chip in the top-left corner — Printing / Paused / Finished / Error — bucketed using the same classifyPrinterStatus rules that drive the printer-card badges, with Idle deliberately suppressed so a wall of cold printers stays visually quiet. Full adds a bottom info strip on tiles whose state is Printing or Paused: the active file's subtask_name ?? gcode_file, the rounded progress percent, Layer N/M when both are known, and the remaining time formatted by the existing formatDuration(remaining_time * 60) helper from utils/date.ts — so the numbers match what the printer card shows for the same printer. When the printer's known HMS errors are non-empty (filtered via the existing filterKnownHMSErrors from HMSErrorModal), the chip flips to the red Error colour with a lucide-react AlertTriangle icon inline. The whole overlay layer is gated by connected — disconnected and paused-mode tiles render the existing offline / paused placeholders unchanged. Zero new network cost. CameraWall.tsx already ran useQueries({ queryKey: ['printerStatus', id], ... }) against every printer for the connected flag; the patch widens the useMemo to expose the full PrinterStatus payload and threads state, progress, remaining_time, layer_num, total_layers, subtask_name, gcode_file, and the filtered HMS error count into each CameraTile — same shared React Query cache the PrinterCard flow populates, so Cards ↔ Cam Wall flips remain instant and the wall opens no second status fan-out. Settings. Per-user, persisted in localStorage under camWallStatusMode alongside the existing camWallMaxLive and camWallSnapshotSec keys. The picker is a three-segment button row inside the existing cam-wall settings popover (gear icon, click-outside dismiss), labelled Off / Compact / Full. Default Full because the cards already show this info — users who pick cam-wall view still want to glance the same details without flipping back. CameraTile contract. All new props (statusMode, printerState, progress, remainingMin, layerNum, totalLayers, printName, hmsErrorCount) are optional with safe defaults, so the 5 existing vitest cases in CameraTile.test.tsx continue to pass unmodified — the status layer is purely additive on the leaf component. The state-bucket classifier lives co-located in CameraTile.tsx (mirrors PrintersPage.classifyPrinterStatus for RUNNING/PAUSE/FINISH/FAILED) so the tile renders correctly even if called outside the cam-wall scheduler. Temperatures intentionally not surfaced. Nozzle / bed / chamber readouts would crowd the tile and overlap the existing top-right LIVE/SNAP/OFF mode indicator and bottom-edge printer name; the printer card remains the canonical surface for those. i18n. 7 new keys under printers.camWall (layer, timeLeft, statusMode.{off,compact,full}, settings.statusOverlay, settings.statusOverlayHint) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) — no English fallback. State chip labels reuse the existing printers.status.{printing,paused,finished,error,idle} keys so no new translation work was needed for the bucket vocabulary. Parity script check-i18n-parity.mjs adds two legitimate-cognate exceptions: Compact for French (same word) and Off for Italian (universal loanword); both remain real translations in every other locale. Parity check 5388 leaves per locale. Scope. No backend change. No new request. No new permission. No DB migration. The toggle defaults to Full, so installs see the overlay the first time they open Cam Wall — flipping to Off reverts to the original camera-only behaviour.
  • Cam Wall view on the Printers page — New view toggle next to the card-size selector flips the entire printers list into a responsive grid of live camera tiles (CardsCam wall). Reuses the existing per-printer FTP / RTSPS proxy on /api/v1/printers/{id}/camera/stream, so the backend ffmpeg fan-out is the same one EmbeddedCameraViewer already drives — no new server-side state machine. Bandwidth ceiling matters on the RPi installs ([[bambuddy-install-base-2026-06-20]] documents that the median deployment is a Pi 4): each live tile is one TLS pull + one MJPEG fan-out. To stay sustainable on a Pi 4 with 8+ printers, only the tiles currently on-screen are live, and only up to Max live streams (default 4) at any moment — everything else falls back to per-tile snapshot polling against /api/v1/printers/{id}/camera/snapshot at a configurable interval (default 8 s). Tiles that scroll off-screen pause entirely. Architecture. frontend/src/components/CameraTile.tsx is the leaf — three modes (live / snapshot / paused), a single <img> element with loading="lazy", an onError no-signal fallback, and a useEffect cleanup that POSTs /camera/stop (with keepalive: true) on mode-out-of-live AND on unmount so the backend releases the transcoder slot. Same /camera/stop discipline EmbeddedCameraViewer uses, so a tile that scrolls off the wall is byte-identical to closing a floating viewer. frontend/src/components/CameraWall.tsx is the scheduler — an IntersectionObserver (threshold 0.4 to avoid flicker at scroll boundaries) tracks visibility, then a useMemo walks the printer list in sort order and assigns the first N visible tiles to live, the rest of the visible set to snapshot, and off-screen tiles to paused. The walker is stable on a given render (no LRU eviction churn) which avoids the "tile flickers between live and snapshot every frame" failure mode. Reuses the same ['printerStatus', id] React Query cache each PrinterCard already populates, so flipping between Cards and Cam Wall is instant and the wall doesn't open a second status fetch fan-out. Clicking a tile honours the existing Settings → camera_view_mode preference — opens the floating EmbeddedCameraViewer when set to embedded, otherwise pops the /camera/:id window with the saved size/position from cameraWindowState. Settings. Both knobs are per-user, persisted in localStorage (camWallMaxLive, camWallSnapshotSec) — not a global backend setting, since a Pi 4 user and a NUC user looking at the same install want different caps. Bounded [1, 16] for max live and [2, 60] seconds for snapshot interval, both rendered as an inline gear-icon popover above the grid with click-outside dismiss. The Cam Wall button is permission-gated on camera:view; viewers without the permission see it disabled. The card-size selector goes opacity-40 + pointer-events-none in cam-wall mode (tile size is governed by the responsive grid, not the cardSize knob). i18n. 13 new keys (printers.pageView.cards, printers.pageView.camWall, printers.camWall.{noPrinters,noSignal,live,snap,off,summary}, printers.camWall.settings.{title,maxLive,maxLiveHint,snapshotInterval,snapshotIntervalHint}) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) — no English fallback. Parity check 5369 leaves per locale. Tests. 5 new vitest cases in frontend/src/__tests__/components/CameraTile.test.tsx cover live URL emission with fps=8, snapshot URL emission with the cache-bust counter advancing on the interval, offline placeholder for disconnected printers, paused placeholder rendering, and the /camera/stop POST firing when the tile transitions out of live. Scope. No backend change. No DB migration. No new permission. The existing EmbeddedCameraViewer is untouched — Cam Wall is purely additive. The printerPageView toggle defaults to cards, so installs see no behaviour change until a user picks Cam Wall.
  • AMS drying badge now shows the active cycle's filament + target temperature — During an active drying cycle the AMS card on the printers page renders Drying · PETG 65°C · 11h 35m left (the loaded-filament line under the slots) instead of the bare Drying · 11h 35m left. Bambu's per-tick AMS push only carries the dry_time countdown — the chosen filament name and target temperature are never echoed on the wire, so the badge had no source of truth for them. BambuMQTTClient.send_drying_command(mode=1, ...) now caches {ams_id: {filament, temp}} on the client; the cache is cleared on mode=0 and on the per-AMS dry_time falling-edge to 0 (same detector that drives the smart-plug-after-drying callback). PrinterManager.get_drying_targets(printer_id) exposes it, printer_state_to_dict and routes/printers.py::get_printer_status thread it onto each AMS dict as dry_target_temp + dry_filament, the AMS schema gains both fields, and the AMS-HT compact badge gets the same render. Falls back to the first loaded tray's tray_type + RFID-recommended drying_temp when no cached target (drying started before backend launch, backend restarted mid-cycle, or cycle started from another source) — the same heuristic the popover already uses to seed defaults. New i18n key printers.drying.targetSummary = {{filament}} {{temp}}°C, translated in all 11 locales (parity check 5356 leaves per locale). 5 new backend tests in TestSupportsDryingCommand (cache populated on mode=1, overwrite on second start, cleared on mode=0, per-AMS isolation across stop) and 4 new tests in TestDryingTargetExposure (cached target wins over fallback, fallback derives from loaded tray, both fields None when no cache + empty trays, targets don't leak across AMS ids). Note about Bambu's printer display. A user reported that with PLA loaded in AMS-A slot 1 and a Bambuddy-initiated PETG 65°C drying cycle, the H2D's own screen showed "PLA" — Bambuddy's wire payload was confirmed correct via journalctl (filament: "PETG" sent, result: success, filament: PETG, temp: 65 ACKed back). The display behaviour is the Bambu firmware labelling the active cycle by the loaded tray's filament rather than the filament field of the command. This Bambuddy change makes our own UI reflect what we actually sent, independent of the firmware's display choice.
  • Continue auto-drying while a print is running on capable hardware — Bambu shipped "Print While Drying" firmware-side on H2D (01.03.00.00+), H2C / H2S / P2S / H2D Pro (01.02.00.00+), X2D / A2L (01.01.00.00+), and X1C (01.11.02.00+). The existing Queue Auto-Drying loop only fires on idle printers — when a print starts, drying stops or never starts, even though the spools may still be wet. New Settings → Print Queue → "Continue drying while printing" toggle (default OFF) lets the same scheduler evaluator also run on the busy printer set. Backend: supports_drying_while_printing(model, firmware) in printer_manager.py is a strict allowlist verified against Bambu's wiki release-notes phrasing ("printing while filament is drying" / "Print While Drying" — every matrix-confirmed model carries that wording verbatim; P1P / P1S / A1 / A1 Mini / X1 (non-C) / X1E are intentionally excluded because the wiki is silent for them, and on those models the firmware would reject the command anyway via dry_sf_reason=[0] (TaskOccupied)). The capability is gated on both display names ("H2D", "X1C", ...) and internal SSDP / MQTT model codes ("O1D", "O1E", "O2D", "O1C", "O1C2", "O1S", "N6", "BL-P001", "N7", "N9") — the printer's model field can carry either, the existing supports_drying precedent uses both. _check_auto_drying in print_scheduler.py now resolves model + firmware up front for every printer and computes mid_print = busy AND toggle_on AND supports_drying_while_printing; when mid_print is True the busy-skip, queue-only-skip, and idle-skip gates are bypassed and the existing humidity / dry_sf_reason / drying-presets / mode-1 send path takes over. Safety: drying temp is capped at max(40, preset_temp - 5) for mid-print drying — Bambu's own release notes for H2D and P2S spell out "Lower drying temperature during printing" / "The drying temperature must not exceed the filament's softening temperature", so a 5 degC offset from the idle preset (floor 40) protects spools inside a hot enclosure during an active print. The early-return guard that short-circuits the evaluator when "only queue mode is on AND nothing scheduled" was also extended to skip the short-circuit when print_drying_enabled is on — otherwise busy printers would never be reached. The manual drying button on the AMS card needs no UI change: routes/printers.py::start_drying has no Bambuddy-side is_idle gate; the "printer busy" rejection comes from firmware dry_sf_reason=[0], which simply won't appear on supported firmware mid-print. The new capability flag is also surfaced on PrinterStatus.supports_drying_while_printing so the frontend can light up the AMS card affordances correctly. Settings. New print_drying_enabled: bool = False in schemas/settings.py, added to the boolean allowlist in routes/settings.py (_BOOL_KEYS), and threaded through the existing dirty-detection / save call in SettingsPage.tsx. i18n. 2 new keys (settings.printDryingEnabled, settings.printDryingEnabledDescription) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5354 leaves per locale, no English fallback. Tests. 7 new cases in TestSupportsDryingWhilePrinting cover every supported display name + internal code, below-min firmware, excluded models (P1*, A1, A1 MINI, X1, X1E), missing firmware, None model, case-insensitivity, and the strict unknown-model default (False — unlike supports_drying which leniently allows unknowns). 4 new scheduler integration cases in TestMidPrintDrying cover: toggle ON + capable hardware fires drying at the 40 degC cap for PLA, PETG caps to 60, toggle OFF still skips busy printers, and toggle ON with too-old firmware / excluded model still skips. Full pytest -n 30 green (4251/4251 in 49 s). Backend ruff clean. Frontend npm run build clean. Scope. No DB migration. No new permission. The new toggle is opt-in (default OFF) — existing installs see no behaviour change until a user enables it, and the firmware is the ultimate arbiter via dry_sf_reason so being too permissive here costs nothing.
  • Batch / mass edit on the Filament tab (#1795, requested by RoBoT24-web) — Bulk operations land on the Inventory page in both built-in and Spoolman modes. Reporter wanted "ten of the same spool, set a pressure advance value, save once" — the existing flow forced ten round-trips through the per-spool editor. Frontend. A new checkbox column anchors the leftmost slot of every row in the table view (header checkbox toggles every visible row; group rows expose a single checkbox that selects every member). As soon as one row is selected, a sticky toolbar appears above the list with Edit / Print labels / Reset usage / Archive (or Restore in the Archived tab) / Delete / Clear selection. The selection clears automatically on any filter or tab change so the toolbar count can never drift from what's on screen. A new BulkEditSpoolsModal is the entry point for the bulk-edit action: a three-state-per-field form (untouched / set-to-value) over the flat spool attributes — material, subtype, brand, color name + RGBA, storage location, slicer filament name + ID, cost / kg, note, label weight, core weight, category, low-stock threshold %. The reporter's pressure-advance use case (K-profile) stays per-spool because K-profiles are scoped per (printer, extruder, nozzle_diameter) and bulk-applying a single K-value across heterogeneous printers would create wrong calibration — they're handled in the existing per-spool K-profile editor instead. Clearing fields in bulk is intentionally NOT supported (user decision on #1795): bulk-set lets you only WRITE non-empty values; emptying ten notes by mistake is a one-click disaster the dialog doesn't expose. The per-spool editor remains the path for clearing. Same dropdown controls the per-spool editor uses. Material, sub-type, brand, category, slicer preset name, and slicer filament are all rendered through a new SearchableSelect component matching the per-spool form's pattern (text input + chevron + filtered list of buttons, click-outside + Escape close). No native <select> anywhere in the modal. Material / sub-type / brand options merge the canonical MATERIALS / KNOWN_VARIANTS / DEFAULT_BRANDS constants from spool-form/constants.ts with whatever's already in inventory. Slicer-preset dropdowns fetch the same sources as the per-spool form (Bambu Cloud presets when signed in, Orca Cloud profiles, local presets, built-in filaments) via three useQuery calls gated on isOpen so closed modal pays no fetch cost; results pipe through the shared buildFilamentOptions(...) helper so the option list is byte-identical to what the per-spool editor shows. Storage location is a searchableClosed SearchableSelect over actual api.getLocations() rows mapped to location_id (the FK), matching the per-spool form's behaviour (rather than the legacy free-text storage_location column, which would have written to a different column than the per-spool editor). Backend. Four new endpoints per inventory mode, eight total: POST /api/v1/inventory/spools/bulk-update, bulk-delete, bulk-archive, bulk-restore (built-in) and the matching /api/v1/spoolman/inventory/spools/bulk-* (Spoolman). All gated on the existing INVENTORY_UPDATE / FILAMENTS_UPDATE permissions used by the per-spool routes. The built-in update endpoint runs the same prepare_internal_spool_payload(...) path as the per-spool PATCH (location resolution, weight-lock auto-stamp on explicit weight_used — both inherited identically). The Spoolman update endpoint loops the existing per-spool update_spool route function so the complex filament re-linking / extra-dict / extra-lock / shared-filament-detection rules stay byte-identical to single-spool edits — the bulk route is just a fan-out, not a parallel reimplementation. Per-spool failures inside the loop are collected and returned as {updated, errors: [{id, status, detail}]} so one bad ID never aborts the batch. The built-in archive endpoint reports {archived, already_archived, not_found} so the UI can distinguish "no-op because already archived" from "missing row." Both modes broadcast a single inventory_changed WS event at the end of the batch instead of one per row, so the table refresh is a single re-fetch. Spoolman bulk-delete / archive / restore now also catch non-HTTPException mid-batch — earlier these three caught only HTTPException; a mid-batch httpx.ConnectError / TimeoutError / KeyError aborted the route with a 500, the loop's accumulated state was lost, and the inventory_changed broadcast was skipped so the table didn't refresh past the partial state. bulk_update_spools got this right out the gate; the audit pass added the same except Exception arm to the other three so a transient Spoolman blip surfaces in the per-row errors array instead of obliterating the whole batch. All-failed and partial-failure are surfaced to the user. The first cut of the four onSuccess mutation handlers only read the success count, so a response of {updated: 0, errors: [50 entries]} (e.g. every selected ID was deleted by another user before the click landed) showed a green "0 spools updated" toast and silently cleared the selection. The handlers now branch on three outcomes — all-succeeded (existing success toast), partial ({ok, failed} warning toast), and all-failed (red error toast + selection preserved + modal stays open so the user can retry). Same shape for delete / archive / restore. bulkResetConsumedCounterMutation.onSuccess now closes the confirm modal + clears the selection — earlier inconsistency with the other three bulk mutations left the confirm dialog open after the action. Invalid RGBA hex is now flagged inline instead of being silently dropped from the patch. Typing "RED" or "FF00" in the colour field now paints the input red with helper text and disables the Apply button via a new hasDroppedTickedField guard that detects any ticked field whose value gets normalised away — without this guard the user clicked Apply, the rgba was silently omitted, and the success toast still fired for the other fields. Backend tests. 17 new integration cases. 10 in test_inventory_bulk.py covering update applying to multiple rows, unknown IDs reported in not_found, empty update body rejected with 400, weight-lock auto-stamp parity with per-spool PATCH, empty ids rejected with 422, bulk delete with mixed valid/invalid IDs, archive setting archived_at on multiple rows + skipping already-archived, restore the symmetric inverse. 7 in test_spoolman_inventory_bulk.py covering the Spoolman update calling update_spool_full once per ID with the same payload, per-spool exception collected without aborting the batch (404 on one ID + 2 successes returns {updated: 2, errors: [{id, status: 404, ...}]}), empty update rejected, empty ids rejected, bulk delete fan-out, bulk archive calling set_spool_archived(spool_id, archived=True) for each ID, bulk restore the inverse. Full pytest -n 30 green (6384/6384 in 68 s). Frontend behaviour. Selection state is per-page-session — leaving the Inventory tab and coming back clears the set, mirroring the existing label-printer scope. The action toolbar collapses into the existing ConfirmModal for destructive operations (Delete is variant: 'danger'; Archive / Restore / Reset usage are 'warning'). Errors surface via the existing useToast. API client. Added bulkUpdateSpools / bulkDeleteSpools / bulkArchiveSpools / bulkRestoreSpools and the four bulkXSpoolmanInventorySpools equivalents — matches the per-mode pattern already used for bulkResetSpoolConsumedCounter. i18n. 42 new keys under the new inventory.bulk.* namespace (33 toolbar / modal / confirm + 4 partial-failure toasts × 4 actions + invalid-hex inline helper + 1 useCustom autocomplete affordance), translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5345 leaves per locale, no English fallback. Scope. No DB migration. No new permission. SQLite + Postgres parity verified — the bulk endpoints use the same model + ORM paths as the per-spool routes. Grid (card) view does NOT get checkboxes in this drop — the reporter explicitly requested the Filament-tab list (table view); adding card checkboxes can ship as a follow-up if asked.
  • Spoolman weight tracking for no-3MF "Untitled" prints (#1820, requested by ojimpo) — Closes a long-standing parity gap between Bambuddy's two inventory modes. When a Bambu print starts that Bambuddy can't fetch a .gcode.3mf for — typically an unsaved BambuStudio project, where the printer reports subtask_name: 名称未設定 ("Untitled") and FTP returns 550 for every candidate path — the existing flow created a fallback archive but Spoolman saw no weight change for that print. The internal-inventory side already handles this via the Path 2 AMS remain%-delta fallback in usage_tracker.on_print_complete (line 517). Spoolman now mirrors the same shape. store_print_data now captures tray_remain_start (per-slot remain% + tray_uuid at print start) on every print — keyed "<ams_id>-<tray_id>", slots with invalid remain (e.g. -1, AMS hasn't read the spool yet) silently dropped, VT external trays encoded as ams_id=255 to match internal — and no longer early-returns when the 3MF is missing: it creates an ActivePrintSpoolman row with filament_usage=None carrying only the snapshot, so the completion path has something to work with. report_usage keeps its 3MF path as the primary writer and adds _report_remain_delta_for_slots for any slot the 3MF path didn't cover (no-3MF entirely OR partial coverage where slice_info omitted a slot). The fallback resolves each slot to its Spoolman spool via the existing spoolman_slot_assignments table, looks up the curated Filament.weight from the spool's filament record, and writes (start_remain - current_remain) × weight / 100 grams via client.use_spool(...). No tray_weight from MQTT — the failure mode #1119 documented (non-RFID spools have no MQTT tray_weight, so remain% × tray_weight gave garbage and silently mis-tracked) is dodged the same way internal inventory dodges it: by reading the user-curated reference weight from the inventory store rather than trusting MQTT's raw field. RFID gate not needed — Spoolman's curated Filament.weight is present for RFID and non-RFID spools alike. Mid-print spool swap detection — when tray_uuid differs between start snapshot and completion read, the slot is skipped rather than mis-attributed. We don't know how much of the print went to which spool; preserving correctness is better than guessing. Double-charge guard — slots already written by the 3MF path land in a handled_global_tray_ids set that the fallback consults before charging, so a 3MF-covered slot can't also pick up a remain delta. #1119 invariant preserved — the deprecated AMS-remain%-based GLOBAL writer is still gone. This is per-slot, per-print, gated on a valid start/current remain AND a resolvable Spoolman spool. No new setting, no toggle: the parity rule [[feedback_inventory_modes_parity]] applies — same shape as internal inventory, which is unconditional. No-op default — installs with no Spoolman slot assignments, no RFID-readable AMS, or no print-time remain% (printer offline at start, AMS still loading) see no behaviour change. DB. active_print_spoolman gets a new nullable tray_remain_start TEXT column via _safe_execute(ALTER TABLE … ADD COLUMN), and the existing filament_usage TEXT NOT NULL is relaxed to nullable — for SQLite via writable_schema = ON + sqlite_master patch + schema_version bump (same surgical pattern used for users.password_hash NULL relaxation a few hundred lines below), for Postgres via ALTER COLUMN … DROP NOT NULL. SQLite + Postgres parity verified. CREATE TABLE updated to emit the new shape on fresh installs. Tests. 11 new unit cases in test_spoolman_no3mf_remain_fallback.py: 5 for _snapshot_tray_remain (valid remain captured, invalid remain skipped, VT tray encoding, empty raw_data, missing uuid defaulted to ""), 3 for store_print_data no-3MF behaviour (row created with snapshot when no 3MF + valid remain; no row when neither 3MF nor remain; 3MF path also captures snapshot for partial-coverage fallback), 3 for report_usage remain-delta (writes (start-end) × Filament.weight / 100 to resolved spool; skips swapped spool when tray_uuid changed; skips slots already handled by 3MF). Full pytest -n 30 green on the Spoolman + tracking + archive + on-print suites (1205/1205). Backend ruff clean.
  • NTP-gate state exposed on the appliance endpointGET /api/v1/system/appliance gains a time_synced field returning "ok", "warning", or null. Source: /run/bambuddy/time-synced, written by the appliance's ntp-gate.sh once chronyd reports sync (or after a 3-minute timeout with a "warning" marker). The RPi 5 has no battery-backed RTC, so on a fresh boot the system clock is wrong until NTP catches up — JWT expiries and TLS certificate validity windows depend on this being right. New backend/app/core/local_config.py::read_ntp_gate is defensive on every failure mode (file absent → None, OSError → None + warning log, empty / unknown content → None, binary garbage survives via errors="replace"). The endpoint stays no-auth; the SPA can use the field to render a "time not synced" badge on a fresh appliance before swapping to normal status once "ok" comes through. 8 new unit cases for read_ntp_gate (absent / ok / warning-suffixed / warning-only / empty / unknown-marker / leading-whitespace / binary-garbage) and 3 new integration cases for the endpoint field (ok / warning / absent). On Docker / manual installs the gate file doesn't exist so this is a no-op (time_synced is null) — the appliance is the only consumer for now.
  • Appliance locale defaults endpointGET /api/v1/system/appliance returns the hostname/timezone/locale the Bambuddy Appliance setup wizard collects into /etc/bambuddy/local.toml during firstboot. New backend/app/core/local_config.py::read_local_toml parses the file defensively (missing file → empty dict, invalid TOML → empty dict + warning, non-string values dropped with a warning), so a malformed file never blocks startup. Endpoint returns {hostname, timezone, locale} with null for any field not present, requires no auth (the frontend i18n bootstrap fetches it before auth might be set up, and the contents are user-set defaults, not secrets). On the frontend, i18n/index.ts runs a one-shot applyApplianceLocale() hook after init: gated by a bambuddy_appliance_locale_consumed localStorage flag so it runs exactly once per appliance, fetches the endpoint, and i18n.changeLanguage(...)s if the returned locale is in the supported set. Non-appliance installs (Docker, manual) silently no-op when the file or endpoint is absent. The appliance writes the file via its setup wizard (separate repo: bambuddy-appliance); this PR closes the loop for the locale field — hostname and timezone are still applied by the appliance's firstboot.sh via hostnamectl/timedatectl and don't need a main-app reader. Backend test coverage: 9 unit cases for the reader (missing/empty/comment-only/full/partial/invalid/non-string/unknown-keys/escaped-quotes), 4 integration cases for the endpoint (nulls when no file, full values, partial values, no-auth-required).
  • Unified print dispatch through the queue scheduler (#1625, by EdwardChamberlain) — Every print Bambuddy starts now goes through the print queue's scheduler rather than the standalone background_dispatch.py path that previously ran in parallel for File Manager prints, archive reprints, and printer-card upload-and-print. Same end-state (a print on the printer), one code path. Effect on users: every print is now queueable, cancellable, visible on the queue page, attributable to the user that started it, and runs through the existing filament-deficit check and print-queue ownership model. The "stealth print" that didn't show up in the queue because it bypassed the scheduler is gone. Architecture. File Manager Print, archive Reprint, and printer-card upload-and-print all now POST to the existing queue routes (POST /api/v1/queue/items with an immediate ASAP scheduled_time) — the scheduler picks it up on the next tick and runs the same dispatch path the existing queue used to. The retired background_dispatch.py route + services/background_dispatch.py worker + their two test files are removed. The scheduler already had every feature background_dispatch did (per-printer locking, status broadcast, error path) plus the deficit / ownership / queue-position machinery, so this is consolidation rather than a rewrite. Permission scope changes. Documented in the Security section below (#1625 introduced the queue:create requirement on File Manager / archive reprint / upload-and-print). i18n. New queue.actions.startPrint key added across all 11 locales (the FileManagerPage button's accessible-name on the new path). Parity check holds. Tests. All background_dispatch test files removed (the routes they covered no longer exist); test_dispatch_force_timelapse.py, test_scheduler_force_timelapse_wiring.py, and test_cleanup_forced_timelapse.py consolidated onto the scheduler since force_timelapse now lives there exclusively. Followup #1625-followup (this drop, listed under Fixed below) caught three issues from the post-merge audit — ownership gate mismatch on /queue/{id}/start and /queue/{id}/stop, an ASAP TOCTOU race on empty-scope inserts, and a missing duplicate-position validator on /queue/reorder — none of which were introduced by this PR but all of which became more impactful once every print routed through the queue. Scope. No DB migration. No new permission (queue:create already existed; this PR widens its surface). No frontend behaviour change for users with full permissions — the queue surface absorbs prints that previously skipped it.
  • HMS error actions — Resume / Stop / Check Assistant from the dashboard (#1743, by Ichicoro, requested in #1419 by Ichicoro) — Bambu's HMS error dialog goes from read-only to actionable.

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.