# Changelog All notable changes to Bambuddy will be documented in this file. ## [1.2.5.4] - 2026-08-29 ### Added - **Sub-projects on the Projects page can be folded away (#2991)** — A project with sub-projects drew every one of them expanded underneath it, at every level, with nothing to shut. That is fine for two projects and unusable for a three-level hierarchy over a couple of hundred archives, where the page becomes one long scroll before the first thing you were looking for. Each group's "Sub-projects of X" caption is now a chevron that folds that group, and a **Collapse** pill next to the status filter tabs sets the default for the whole page and is remembered across reloads. The caption stays visible when a group is shut — it is the way back in, and it carries a count of what is behind it. That count is of the cards actually nested there, not the card's own sub-project badge: the API counts sub-projects across every status on purpose, so under the default Active filter the badge can legitimately say 2 where only one card will unfold. A group folded by hand deviates from the default until the pill is pressed, which resets those deviations rather than leaving a group defying the switch that was just flipped. Nothing changes for a page with no nesting, where the pill is not shown at all, and the default with no stored preference is still fully expanded. - **Dutch (nl) is now a supported interface language (#2891, requested and contributed by @Igiegel)** — Adds `nl` as the fourteenth locale, listed as "Nederlands" in the language picker. The translation was contributed as a file on the issue and needed three corrections before it could be wired up, all of which the parity gate found. First, the nine `stats.timeframe.*` entries had their **keys** translated along with their values (`'today'` had become `'vandaag'`), which would have left the Statistics timeframe selector resolving nothing and rendering raw key names for every Dutch user — the values were kept and the keys restored. Second, the file was translated against an older `en.ts` and was 84 leaves short, missing the Filament Track Switch feed prompts, the AI-detection status strings, the no-3MF internal-history banner, the batch-order stranded-plate notices, the Avery starting-position field and the whole `locationHaSensors` section from #2824; those were translated and added. Rather than splice them in, `nl.ts` was regenerated from the `en.ts` skeleton with the contributor's strings carried over, so its structure, key order and section comments now match the reference locale exactly and a future diff against `en.ts` reads as content rather than as reordering. Third, 229 leaves were identical to English; each was checked individually and all were kept, because Dutch takes most technical UI vocabulary verbatim — printer, filament, status, nozzle, timelapse, dashboard — and Dutch slicer users use the English feature names (support, ironing, prime tower, gap fill) untranslated. Those 123 distinct values are now listed explicitly in a `NL_COGNATES` allow-list in `check-i18n-parity.mjs`, the same shape the other twelve locales use, so the exemption is an enumerated translator decision rather than a blanket skip. Parity green at 6264 leaves across all 14 locales. - **A spool can carry a different filament preset on each printer model, and its K profiles are picked per hotend** — A slicer preset is bound to a printer model: `Bambu PLA Basic @BBL X1C` is not the same preset as `@BBL H2C`. A spool stored exactly one, which was right until the same spool was used on a second model — the AMS slot on the other machine was then configured with a preset that machine has no profile for. The spool form's PA Profile tab is now a **Printers** tab holding both halves of the answer: a model list on the left, and on the right that model's filament presets and the K profiles for each of its hotends. Presets are keyed on the printer *model*, because `@BBL X1C` is the same preset on every X1C you own and asking once per machine would mean picking the identical value twice; K profiles stay keyed on the individual printer, extruder and nozzle diameter, because a K value is measured on one physical hotend and two machines of the same model legitimately differ. Both halves cover **every nozzle size — 0.2, 0.4, 0.6 and 0.8 — not only the size currently fitted**, because a spool is configured once and nozzles get swapped: presets get a row per size (the preset is written to an AMS slot, a slot feeds exactly one nozzle, and Bambu names its presets per size anyway), and K profiles are laid out as a grid with size down the side and hotend across the top, a dash marking a size the printer has no calibration for. Anything left alone inherits the spool's own preset and keeps inheriting it when that changes later, so only what actually differs needs an override, and a spool nobody has configured behaves exactly as it did before. Each model is offered only the presets that name it — using the same matcher the Configure AMS Slot modal filters with, now shared between them — while presets whose name identifies no model, which is most user-authored and OrcaSlicer ones, stay available everywhere, and a preset already saved is never hidden from the control that shows it. Every preset carries an origin badge — Bambu Cloud, Orca Cloud, Local or Built-in — in the same wording and colours the Configure AMS Slot modal has used since #1623, because the same filament exists in several of those sources and which one is picked decides what actually reaches the printer. **Auto-match** fills every size of every model with the variant of the spool's preset that names it, preferring the variant for that exact size; a model with no such variant is left inherited rather than given an approximate one. The model list stays one row per model however large the fleet is, and carries a count of hotends still without a K profile so an unfinished spool is visible without opening anything. One limit worth knowing: a per-model override can be one of your own cloud presets, whose id the slicer refuses in a slot's filament field, so such an override configures the slot but is not used as the calibration link — the spool's own preset is used there instead. - **K profiles distinguish High Flow from Standard nozzles** — A printer files each calibration under a nozzle id of the form `HH00-0.4` (high flow) or `HS00-0.4` (standard) and can hold both for one diameter — a maintainer's H2D carries 102 high-flow entries and 6 standard — because the same filament reads a different K through each. The picker labels every profile with the flow it was measured on, saving records it, and a stored profile is no longer applied when the fitted nozzle disagrees; the picker marks such a profile rather than letting it look configured while quietly doing nothing. Two spellings have to agree for that: a calibration entry says `HH00-0.4` while the fitted nozzle reports `HH01`, so the comparison is two characters, not four. An unknown flow on either side matches anything, which is what it must do — every K profile stored before this has none, and an X1C declares none on any profile at all (measured: all eight come back with an empty nozzle id) even though the machine really does take either nozzle, so treating silence as "Standard" and filtering on it would have dropped every X1C profile the moment a high-flow nozzle was fitted. - **Every path that configures an AMS slot respects a spool's per-model preset and per-hotend K profile** — The manual assign in either inventory mode, the RFID auto-assign, the Spoolman tag link, the re-fire when a slot goes from empty to loaded, the re-apply after a calibration-table refresh, and the re-selection when a Filament Track Switch moves an AMS to the other nozzle. The Configure AMS Slot dialog also opens on the spool's own configured values, falling back to the slot's last manual configuration and then the tray's RFID data, rather than ignoring what the spool was configured with on the one screen that looks like it exists for it. A printer card in expanded view now lists every fitted nozzle size rather than the first entry alone, which on a machine with two different sizes named one hotend and implied it was the whole printer. - **An Avery sheet can start at the first unused position, so a part-used sheet is not thrown away (#2879, requested and contributed by @whitigol in #2918)** — Label PDFs always began in the top-left slot, so the second batch printed onto a sheet that already had seven labels taken off it would have printed over the gaps. Spool labels are printed a few at a time as filament arrives, which meant spending a 30-slot Avery 5160 sheet on two labels or nothing. A **Starting label position** field in the print-label dialog now says which slot to begin at, counted the way the sheet reads — left to right, top to bottom, starting at 1. The offset applies to the first page only and later pages restart at slot 1, because the number describes the sheet already in the tray rather than anything about the job: the second sheet the printer pulls is a fresh one. Position 1 is the default and is what a request that omits the field means, so both label endpoints — built-in inventory and Spoolman — behave exactly as they did for anyone who never touches it. The bounds are per template and enforced on both sides, 1–21 for Avery L7160 and 1–30 for Avery 5160; the server derives them from the sheet layout table it already lays labels out from rather than carrying a second copy of the numbers, so the two cannot drift, and a non-default position is refused outright for the single-label roll templates where a sheet slot means nothing. In the dialog the two sheet buttons disagree about the same number — 25 is valid on a 5160 and off the end of an L7160 — so the button whose capacity the value exceeds is disabled and its hint is replaced by that sheet's own range, instead of leaving a disabled button next to guidance that says the value is fine. The sentence naming the skipped slots deliberately avoids i18next's reserved `count` variable: with no `_one` form defined, a value of 1 resolves past the translation to the English default, so every non-English locale would have printed an English sentence at position 2 and only at position 2. Translated in all 13 locales, README and wiki updated, and covered by renderer, endpoint and dialog tests including the page-boundary cases where the selection exactly fills the offset first sheet and where it runs one past it. - **A fault's description is in the status response, so a client no longer needs its own copy of the table (#2926, proposed and analysed by @sadontsev)** — `HMS_ERROR_DESCRIPTIONS` has been in the backend all along and the status response never carried it, so every consumer that wanted to tell a user *why* a print halted resolved the same 853 codes from its own duplicate of the same sentences — this repo's Python table, the frontend modal's, and at least one third-party iOS client whose catalogue exists purely because the server would not say. Each aged separately, and a push relay watching a printer could only manage "your printer needs attention" while the server already knew it was "Filament ran out. Please load new filament." `hms_errors[]` entries now carry `description`, defaulting to null so a client that has never seen the field is unaffected. It is resolved once, where the fault is parsed, rather than at the boundary that happened to prompt the request: there are three separate serializers of a fault — the status response, the WebSocket broadcast, and the print-completion payload the queue's failure reason is built from — and adding it to only the first would have delivered half the feature to a relay watching the stream, which is the likelier consumer. The queue's failure reason now quotes the same sentence instead of resolving the code a fourth time, and the notification path reads it rather than re-deriving its own. Resolving in one place is also what makes the three unable to drift, which is pinned by a test that asserts they agree. Resolution is exactly what the codebase already did, verified rather than assumed: an 8-char `print_error` is the catalogue's `MMMM_EEEE` key split in half, and a 16-char `hms[]` identifier is tried whole and then collapsed to its first and last groups, which is how the notification path, the queue's failure-reason helper and the frontend modal have always resolved those. The collapse is lossy — #2728 counts 65 documented faults falling onto `0300_0001` alone — and it is kept rather than tightened here because refusing it would not read the same data more strictly, it would stop describing faults that are described today and leave this field null while the UI shows text for the same fault. Narrowing it belongs with #2728, where both key spaces can move together. A fault the catalogue does not cover reports null and is still reported in full; `full_code` identifies it either way. The equivalence is pinned by a test that checks every catalogue code in both fault shapes across all three alert levels, so a future change to the lookup cannot silently stop notifications from firing. The text is English only and unlocalized, which the schema says next to the field. The frontend keeps resolving its own text for now; switching it over would change what `filterKnownHMSErrors` counts across eight call sites, which is #1840 and #2728's argument rather than this one's. - **A virtual printer can be told which address to advertise, so uploads work on Docker bridge networking (#2930, reported and diagnosed by @sebimarkgraf)** — `VIRTUAL_PRINTER_ADVERTISE_ADDRESS` sets the address written into the MQTT status that BambuStudio and OrcaSlicer read their FTP upload destination from. It exists for deployments where that address is not one of the container's own interfaces: on bridge networking the virtual printer is reached on the host's LAN IP but binds a private one like `172.24.0.2`, and that private address is what the slicer was handed — so it opened an FTP connection to an address that does not exist on its network, which is the upload stalling around 10% with "Failed to send" that the troubleshooting page has been describing as a limitation with no fix. Set it to the address slicers use, alongside the `VIRTUAL_PRINTER_PASV_ADDRESS` that already existed for the passive-data channel. The log line that arms the rewrite names its source, so `(VIRTUAL_PRINTER_ADVERTISE_ADDRESS)` versus `(bind_address)` says whether the variable reached the container. A value that is not a dotted-quad IPv4 is refused with one warning naming it and the address that would have been used before is used instead — deliberately, because refusing to rewrite at all would put the *real printer's* IP back in front of the slicer, which is the leak this path exists to close and strictly worse than the wrong local address. `0.0.0.0` counts as unset, and surrounding whitespace is tolerated for the sake of values pasted into a compose file. This is an environment variable rather than a change to how the advertised address is resolved, and that was the decision worth making carefully: the virtual printer already has a "Network Interface Override" field, but it feeds SSDP and the certificate's SAN list only, and reading it here would have moved the upload destination on every install that has one set — the multi-NIC, VLAN and Tailscale setups, which are the ones most likely to have been arrived at by hand and the least likely to survive being second-guessed. Unset, nothing about the resolution changes, which is pinned by a test. Host and macvlan networking still need none of this and remain what Virtual Printer is developed against; the variable removes one blocker rather than making bridge mode equivalent, and the wiki now says so in the three places that previously stated the host address could not be discovered at all. - **Printer file downloads can be selected in ranges and print-history videos can be downloaded (#2850, requested and contributed by @logikal in #2853)** — The printer file browser's multi-select download now prepares large selections on the app data volume instead of buffering them in server and browser memory, uses per-file compression (videos stored, G-code/3MF compressed), reports partial results and preparation progress, supports cancellation, rejects over-large or under-space selections, and preserves the legacy API contract. Shift-click selects a contiguous visible range and hidden selections are discarded when navigating or filtering. Print History now offers attached timelapses, matching printer timelapses, and `/ipcam` chunks when available; offline or unreadable storage is distinguished from an empty directory. Download tokens are single-use and resource-bound, API-key printer allowlists are enforced, FTP short reads are rejected, and abandoned staging is pruned. Translated in all locales; wiki updated. Covered by backend and frontend regression tests. - **Bambuddy now asks a printer that refuses FTPS what it actually said (#2780, measured by @grolmus)** — When a printer's file service answers port 990 with something that is not TLS, Python reports `[SSL: WRONG_VERSION_NUMBER]` and the bytes that caused it are gone, consumed by the TLS layer before the error surfaces. That has left #2780 open on a theory rather than a finding. The client now opens one plain connection straight afterwards and reads what the printer says, so the log carries the printer's own words — an FTP refusal such as `421 Too many connections` would identify the fault outright — and the line is marked as the one to quote in a report. Reading nothing is informative too, and says so: a healthy implicit-FTPS service stays silent until it gets a handshake, so silence means the refusal had already passed. It asks once per cool-off window rather than once per attempt, which keeps it to one extra connection per printer per five minutes — the suspected fault is a printer running out of connections, so the diagnosis must not add to it. What made this worth doing is a measurement from a nine-printer farm, reproduced here: a cleartext banner on the TLS port produces exactly the error the field reports, a genuine TLS version mismatch produces a different one, and a client with no version cap reaches a TLS-1.2-only peer unaided. So this failure was never a TLS-version problem, and the per-model `cap_tls_v1_2` knob cannot affect it. Two of the three entries carrying that knob were added on the belief that it could; they are kept, since their reporters saw the symptom clear and nobody here has the hardware to re-test on, but they are now marked for re-test and the reasoning recorded next to them is what was measured rather than what was assumed. Both measurements are pinned by tests, so the explanation stays falsifiable. - **The K value is on the AMS slot itself, not only in the popover (#2532, requested and contributed by @gyrene2083)** — Reading back a slot's pressure-advance value meant hovering it: the K factor lived in the filament popover alone, so checking whether a calibration had actually taken across four slots was four hovers, and comparing two of them side by side was not possible at all. Every slot card now carries the value under the material name, the way Bambu Studio shows it per slot — on regular AMS units, on AMS-HT, and on the external spool of a dual-nozzle machine. Only a value the printer actually reported is shown: a loaded but never-calibrated slot stays blank rather than inheriting the 0.020 that fills the popover's own field, and a slot the firmware reports as exactly 0 counts as uncalibrated the same way the stored K-profiles do. The label is shortened to **K** with the full localized name on hover, because "K Factor", "K-Faktor" and "Facteur K" ate the value itself — the whole point of the line — on cards under about 350px, and the figure is set in tabular numerals so it measures the same in Safari as in Chromium. Where one slot of a unit is calibrated and its neighbours are not, the neighbours hold the same row open so the fill bars stay level across the card. - **Filament Track Switch: the inlet each AMS feeds, and K-profiles that follow it** — With a switch fitted an AMS is not wired to a nozzle any more. It is plumbed into one of the switch's two inlets and reaches both hotends through it, so every unit reports its extruder as "not fixed" and `ams_extruder_map` comes back empty. Bambuddy had nothing to fall back on but the AMS unit number, so AMS-A was badged R and AMS-B was badged L purely because their ids are 0 and 1, a third unit got no badge at all, and every one of those labels was wrong; the SpoolBuddy assign modal had the same fallback in a worse form, mapping anything that was not extruder 1 to R. The binding needed no new telemetry — it sits in bits 24-27 of the same AMS info string Bambuddy already parses for the unit's type and extruder id, and is read only when a switch is installed, because without one "not fixed" really does mean an uninitialised unit. The badge keeps L and R, in its own colour, with the inlet named in full in the tooltip, since the letter is the inlet's position and not a claim about which nozzle that AMS feeds: the switch can route either inlet to either outlet. Both views update live, which took adding the switch fields to the WebSocket payload and to the broadcast dedup key — the binding is not part of the AMS change hash and must stay out of it, because that hash drives Spoolman sync. The calibration half is where it bites. K-profiles are numbered per nozzle, so the same index means a different profile on each hotend, and a tray holds exactly one index: move an AMS to the other inlet and every configured slot silently kept pointing at the old hotend's table. Measured on an H2C, a black PLA calibrated 0.018 left and 0.020 right stayed on the left profile after the move, and a manual RFID re-read only re-asserted the same wrong one. Three separate copies of "which extruder is this slot on" each ended in `else 0`, which on a switch machine filed every profile under the right-hand nozzle; they now share one resolver that returns unknown as its own answer, because unknown and extruder 0 are very different things on a dual-nozzle machine. Moving an AMS now re-selects each configured slot's counterpart profile for the nozzle it has arrived on, and only for spools that already have one there — a slot nobody has configured, or a spool calibrated on one hotend only, is left exactly as the operator set it. Configure Slot resolves against the slot's own nozzle throughout: options name the hotend, a filament calibrated on both gives two distinguishable entries, matches are scoped to the nozzle the slot feeds, and the other hotend's profiles stay reachable under Other K profiles. The print dialog's slot dropdown picks up the same inlet labelling and notes when every filament a print needs sits behind one inlet, which is legal but slow — a change between two spools on the same inlet retracts all the way back to the AMS, where a change across the two only retracts as far as the switch. Assigning an AMS to an inlet stays on the printer: Bambu Studio can read that binding and has no command to write it. ### Changed - **The spool form is wider, and its colour, weight and cost fields have their own tab** — The Printers tab is a model list beside a detail pane, which needs the room. Colour, spool weights, price, category and storage location move out of the bottom of a long scroll into a **Color & Cost** tab, laid out in two columns rather than one short field per row. Filament identity and the slicer preset stay on the first tab. - **Home Assistant sensors moved out of the Smart Plugs settings tab into their own (#2824)** — Sensors are things you read and plugs are things you switch, and with storage-location bindings joining the printer ones the two no longer belong on one page. Both now live under **Settings → Sensors**; nothing about the printer bindings themselves changed, only where they are. The template that carried the printer alert is renamed from "Home Assistant Sensor Alert" to "Printer Sensor Alert", along with the toggle and badge labels that name it, because once a storage-location alert existed beside it the old name no longer said which one it was; the rename only touches templates still holding the old default name, so one that was edited keeps whatever it was called. A battery-class printer sensor now draws a battery icon instead of the generic gauge — the printer map never had an entry for it and the shared one does, which reads as the omission it was rather than a choice worth preserving. - **Storage locations sort the way they are named (#2824)** — The locations list was ordered by `ORDER BY name`, which puts "Drybox 10" between "Drybox 1" and "Drybox 2". It is now sorted on the numbers inside the name, so a rack numbered past nine reads in rack order everywhere the list appears. - **Camera view mode is picked at the camera button, per printer** — Whether a camera opened in its own browser window or as a floating overlay was one dropdown in Settings → General → Camera, applied to every camera on the install. Deciding it per printer meant leaving the Printers page, changing the setting, coming back, opening the camera, and going back again to undo it. The camera button on the printer card is now a split control: the icon opens the camera whichever way you opened the last one, and the caret beside it offers both modes, with the one in effect ticked. Picking a mode opens the camera that way as well as making it the mode the icon uses from then on, because a menu that only changed a preference would leave you a second click to do the thing you had already asked for. The choice lives in your own browser, so two people watching the same farm can each have the view they want; `camera_view_mode` survives as the default a browser that has never chosen starts from, and is written back when you hold `settings:update`. The Cam Wall follows the same remembered mode, and the popup-opening code the card and the wall each had a copy of is now shared, with a corrupt saved window geometry falling back to defaults instead of throwing. The effect that force-closed every open overlay when the setting flipped to window is gone — it made sense for a global switch, not for a choice made per click. No new locale strings: the four the settings control used are reused as the menu's labels and tooltips. - **A file dropped on a busy or offline printer is queued instead of refused (#2849, reporter @abraha2d)** — Dragging a sliced file onto a printer card refused the drop unless the printer was connected and neither printing nor paused. The overlay went red with "Printer busy", the handler returned early, and the file was discarded with no toast and nothing uploaded; the card's Print button was hidden by the same condition, so both routes into printing from the card closed at once and the way through was the File Manager. The gate never described a real constraint. Every print Bambuddy sends becomes a queue item, and dropping onto an idle printer only looks instant because the scheduler dispatches it on the next pass — busy is a timing difference, not a different path, which is why the print dialog has always accepted a busy target and said the job would start later. Offline is included for the same reason: the queue dispatches when the printer comes back, so a machine that is powered down can be given work. The overlay now says which one is happening, "Drop to print" when the job would start immediately and "Drop to queue" when it would wait, using the same predicate the print dialog uses for its own later-start notice so the card cannot promise something the dialog contradicts a second later. The drop is also gated on the permissions the flow actually exercises — `library:upload` and `queue:create`, the pair the Print button beside it has always checked — instead of `printers:control`, which it checked and never used, and which let someone holding it alone get the file uploaded and then rejected by the queue, leaving an orphaned library row behind. - **An archive that arrives with only a name now says what to do about it (#2843, reporter @gyrene2083)** — The no-3MF banner and the connection diagnostic both explained why a print archived with nothing but a name and offered nothing to do about it, and the explanation was wrong in the way that mattered most: both blamed the printer's firmware and said no setting changes it, which reads as "your machine is broken and nothing will help". Measured on hardware: same Bambu Studio, same model, three printers a minute apart, all reporting the external-storage option as on — the H2C and H2D went to internal storage and archived with a name only, the X1C went to the card and archived in full. The same H2C and H2D sliced in OrcaSlicer put the file on the card and archived in full, and turning the option off changed nothing, because OrcaSlicer always uploads over FTPS. So the destination is the slicer's choice, not the printer's, and the option governs neither slicer on this generation. Both strings now name Bambu Studio rather than the firmware and lead with the two routes that take one step — start the print from Bambuddy, or slice in OrcaSlicer — with Send-with-External followed by a separate print start offered as the way to stay in Bambu Studio. Both also mention that a card or stick is still needed, since with an empty slot OrcaSlicer refuses to send at all. Translated in all thirteen locales, and the README callout and this entry now link the upstream issue, bambulab/BambuStudio#10481, so the behaviour can be followed where it is tracked. - **The Watchtower we recommend for daily builds is the maintained fork (#2917, reported by @CamelT0E)** — The daily-build instructions in the README, on Docker Hub and in every daily prerelease pointed at containrrr.dev/watchtower. That project has been archived and read-only since December 2025 and its last release, v1.7.1, is from November 2023, so anyone following the recommendation was being handed a container with Docker socket access that had not received a fix in over two years. Development continues in Nicholas Fedor's fork, which ships as `nickfedor/watchtower` and released v1.21.0 this month. All four references now point at watchtower.nickfedor.com and name the image, including the release-notes template in `docker-publish-daily-beta.sh` that produced the screenshot in the report — the READMEs alone would have left every future daily prerelease repeating the dead link. Existing images keep working; only the recommendation changed. - **The Windows installer build is split in two so a signing request can wait for a human (SignPath Foundation)** — Release tags are Authenticode-signed through the SignPath Foundation OSS programme, and the production certificate does not sign on demand the way the self-signed test certificate does: every request has to be approved by hand in the SignPath UI, because the Foundation verifies what is being signed and which build it came from. The submitting action waits for that approval with a default timeout of 600 seconds, which is ample when the test policy approves automatically in seconds and far too short once the wait is a person noticing a tag went out. A tag pushed at night would have failed the run ten minutes later with the installer already compiled and thrown away. The compile now ends in its own job that uploads the unsigned artifact and stops; a second job downloads it, signs it, and does the release-facing work, with the wait raised to an hour. Because the artifact is uploaded before the wait begins and is addressed by id, a missed approval window is recovered by re-running the second job alone rather than rebuilding the installer — which is the reason to separate them rather than simply raise the timeout in place. The second job runs for unsigned builds too, so the daily prereleases that are deliberately left unsigned to preserve the signing quota keep going out through exactly one set of alias, artifact and release steps. The property that matters is unchanged and now recorded next to the steps that depend on it: none of the alias, upload or release-attach steps carry `always()`, so GitHub skips all three when signing fails or times out, and an unsigned `.exe` cannot reach a release. Nothing about the signed output changes, and the restructure behaves identically under the test policy — the request simply completes immediately instead of waiting — so it can be proven green before the production certificate arrives. - **Generated thumbnails are lit, so one model no longer looks like the next (#2816, requested by @NaegeliJ, contributed by @sadontsev in #2861)** — Both renderers that draw a model themselves — the File Manager's STL/3MF thumbnails and the plate cards — handed matplotlib a mesh with no light source, and without one every triangle is filled with the identical green whichever way it faces. The result was a flat silhouette, so two models of similar outline were the same picture. The mesh is now shaded from a fixed light whose angle is pinned to the camera angle rather than chosen freely: the two are a pair, and a light aimed at the far side of the model gives both visible faces the same brightness and no contrast at all. Lighting then exposed two faults a flat render had hidden. A face wound the wrong way shades as though it faced away, so an STL with inconsistent winding came out patchy like camouflage; winding is now repaired before the render — outward rather than merely consistent, and only for the meshes that need it, since the check is milliseconds where the repair is seconds. And a mesh whose facets are all zero-area or collinear — stub or truncated STLs, 3MFs with an empty triangle list — would have failed outright once lit, so those are detected and still render flat instead of counting as a failure in a folder-wide batch. Both renderers share one copy of the light, the camera and the repair, so a plate card and a library thumbnail of the same model cannot drift apart. Passing the faces as an array rather than a list of lists also cut the collection build on an 82k-face mesh from ~0.19s to ~0.007s, which speeds up the unlit path too. ### Fixed - **Some archived 3MFs lost their G-code when re-imported into the File Manager (#2993, reported via the in-app form)** — they never lost it. The download serves the stored file byte for byte, and the G-code was still in the zip; what differed was who was asked. On the archive side the answer came from the file itself — the green GCODE badge reads the layer count and print time that were parsed out of the plate G-code — while the library decided from the filename alone, so a sliced 3MF stored as `Foo.3mf` rather than `Foo.gcode.3mf` carried the badge and still came back as a source-only project with no Print button. That splits on how the print reached the printer, not on anything about the file — a slicer's LAN send names it `.gcode.3mf`, while a per-plate export or a cloud-dispatched print arrives as plain `.3mf` — which is why it looked random. Both sides now ask the same question of the zip itself, and every route into the library (upload, ZIP import, MakerWorld, external-folder scan) classifies on content rather than on the name. Files already in your library are re-checked once on the next start. The backend was always willing to print these, so this was only ever the interface refusing to offer something that would have worked; a genuine model file is unaffected, and one that now shows **Print** correctly stops offering **Slice**. - **Swapping a spool left the previous spool's preset name on the AMS slot card** — pull a Bambu ABS Orange out of A1, put a PLA Matte Dark Blue in, and the card still read "Bambu ABS" against the new colour. The backend had it right all along: the RFID auto-assign rewrites the slot's stored preset the moment the tag is read. The browser simply never refetched it. The slot card reads that stored preset ahead of the filament id the printer is reporting, so one cached row outranked correct data arriving over the WebSocket — and because every other field on the card (colour, material, fill, K value) rides the status push and updated instantly, it surfaced as a single wrong line rather than an obviously stale card. The manual assign path already refreshed it; the RFID path did not. Spoolman mode was the worse half of the same bug: its AMS sync writes that same row but announced nothing at all, so there was no event to refresh on — it now reports each slot it changed or cleared. Two further changes make the card right without waiting on any of that: the slot's queries no longer sit behind the 3-second cascade debounce meant for print completion (a swap touches one slot, and any further event restarted that timer), and the card now ignores a stored preset whose filament id disagrees with what the printer reports in the slot, so the correct name is on screen from the status push alone. A hand-picked preset name still wins wherever the stored row and the slot agree, and a user or local preset — whose ids genuinely cannot be compared — is untouched. - **A print that could not fetch its own 3MF could be charged another plate's filament (#2957, reported by @doncaruana)** — when the source file is missing, the usage tracker looks for a replacement in the library or in a previous archive and matched on the filename stem alone. That is far weaker evidence than it looks: Bambu Studio writes the printer-side filename from the project's `Title` metadata, so every plate of a project arrives on the printer under one name however the file was renamed on disk. The reporter's single-filament job was handed a previous archive's three-filament plate and three spools were debited for material they never extruded, with nothing on the archive to say the numbers were someone else's. A candidate is now refused when it holds a different plate than the one running, and an all-plates export is refused unless it actually contains that plate — previously the plate was looked for later, found missing, and every filament in the file was summed onto one plate's print. Where the printer echoes only the 3MF filename and the plate cannot be known at all, which is the reporter's own firmware, the candidate is still accepted on its name and a warning now says so rather than the deduction happening silently. - **A slow-but-healthy 3MF download was cut off at 30 seconds (#2957, reported by @doncaruana)** — `ftp_timeout` is handed to every download as *both* the socket inactivity timeout and the whole-transfer deadline, which makes its default a cap on how big a file a printer is allowed to serve. The reporter measured the same 5.4 MB 3MF at 45 s off a worn P1S SD card and 25 s off a new one, and a 15.15 MB 3MF at 105 s; an older 7.8 MB archive in his logs survived only because the transfer happened to finish inside the retry grace. None of those links were broken — they were slow, which is what the inactivity timeout exists to tell apart. The total deadline now follows the size the printer reports for the file, against the same pessimistic 25 KB/s floor the upload path has used since #2529. The extension is granted only once the printer has answered `SIZE`, so a printer that is not answering at all still fails on schedule and the executor queue wait #2572 bounded is unaffected, and it is capped at five minutes because the print-start handler holds a pooled database connection for the length of its 3MF hunt. A transfer that overruns even that stretched deadline is not retried, for the reason an overrunning upload has not been since #2529: the retry would spend another full deadline reaching the same conclusion. - **Bambuddy could run two heavy FTPS transfers against one printer at once (#2957, reported by @doncaruana)** — the reporter watched Bambu Studio itself lose its connection to a P1S while Bambuddy pulled a 12 MB 3MF, and a later log caught two Bambuddy downloads of the *same* 5,250,969-byte file overlapping during print start. A P1S at that moment is already serving the print off the same SD card and answering MQTT. Downloads now take turns per printer, the way uploads have since #2529. The gate is deliberately soft — a download that cannot have it within 30 seconds proceeds anyway, because a print losing its 3MF to queueing would be worse than the contention, and the printer file browser stays outside it entirely so a 3MF preview never waits out somebody else's multi-gigabyte selection — and it is now meaningful at all: the 90-second cap on a multi-path lookup used to return while its worker thread kept walking the remaining paths, still holding the printer's socket, so the walk is cancelled and waited out before the printer is handed on. - **The cover thumbnail re-downloaded a 3MF another part of Bambuddy had just fetched (#2957, reported by @doncaruana)** — the cover endpoint and the print-start archive flow share a 3MF cache so whichever fetches first hands the file to the other (#972), but the cover endpoint consulted it once on the way in and then retried for up to two and a half minutes without ever looking again. In the reporter's log the archive flow published the file 42 seconds into that sequence and the cover's third attempt still pulled its own full copy of it, off a printer that was mid-print. It now looks again before each retry, and a file it picked up that way is left alone rather than re-registered under its own name or deleted on the way out — it belongs to the archive flow. - **A camera snapshot occasionally logged an asyncio ERROR with a traceback into the camera code (#2968, reported by @ceasley)** — `Task was destroyed but it is pending!`, naming `create_tls_proxy.._handle()`, once every few hundred snapshots. Nothing was actually broken by it — the snapshots on either side of each one succeeded — but it is the loudest thing in an otherwise clean log and it reads like a camera fault. `asyncio.start_server` wraps the connection callback in a task and keeps only a weak reference to it, so the RTSPS proxy's handler could be collected while still waiting on its two forwarders; that message is the garbage collector noticing. Teardown had the matching gap: `server.close()` stops the listener but leaves established connections running, so the close waited on a handler that only finishes when the *peer* drops the socket — and by then ffmpeg has already been reaped, so the connection is dead weight nobody is going to close. The proxy now holds its handlers for as long as they run and cancels them at shutdown, which is what `Server.close_clients()` would do natively if it had not landed in Python 3.13 (Bambuddy supports 3.10). Both the snapshot path and the streaming endpoint go through the same shutdown. 7 regression tests, against a real TLS listener rather than a mock, since the bug is in socket teardown. - **Deleting a print with no 3MF left its timelapse and its uploaded source on disk (#2968, reported by @ceasley)** — and logged the ordinary case as a security incident. An archive created without a 3MF carries an empty `file_path`, and both delete paths derived the directory to remove from that path: finding nothing, they removed nothing and wrote `SECURITY: Refusing to delete files for archive 7 - file_path is empty or invalid: ''` at ERROR. That was true when such an archive really was an empty row, and stopped being true once one could own files — `//` holds its timelapse and finish photos, and `archive/no_source//` holds a source 3MF uploaded onto it afterwards. Neither was ever removed, so the row went and the video stayed. That is not a corner case on an H2-series or P2S printer, where a print sent from Bambu Studio *always* archives without a 3MF — it is most of the library, and the space it used never came back. Both are now cleaned up, but by different means, because `/` **shares a namespace with the per-printer folders**: a normal archive lives at `//_/`, so `archive/1` is printer 1's folder *and* the directory the shared path helper hands archive id 1 — and archive ids and printer ids are small integers from unrelated sequences, so on every install the first few archives collide with the printers. A recursive delete there would take every print that printer ever made. So `archive/no_source//`, which is nested a level deeper under a name no printer id can take, is removed whole, while the id-named directory gives up only the files the row itself names — its `photos` subdirectory and its recorded video — and is then removed only if that left it empty, which a printer folder holding prints never is. Anything unrecognised keeps it alive and is left behind rather than guessed at. **The same collision is now refused for corrupted rows**: nothing one level under the archive directory is deleted at all, and since an archive directory has been two levels deep since the first commit, a `file_path` that has lost a path component can no longer point the delete at a printer folder. The shared `/photos` directory that every no-3MF archive once wrote into is never a candidate either, because removing it on one delete would take the others' photos. **Hard delete had its own copy of these rules** — the helper it was extracted from says in its docstring that it exists so the two cannot drift apart, and they had — so it now shares them, which also means it stops skipping the print-log thumbnail cleanup when a guard trips. The `SECURITY:` wording is kept for the two conditions that really are one, and both still remove the row so an archive cannot get stuck in the UI. 20 regression tests. - **Every ffmpeg failure logged its build banner instead of the error (#2968, reported by @ceasley)** — ffmpeg opens each run with about twenty lines of version, build and library banner and prints its diagnosis *last*. Seven of the eleven places Bambuddy logs ffmpeg or ffprobe output kept the first 200 to 500 characters of that, so the banner survived and the error did not. The reporter's log carried twelve camera-capture errors and every one of them read `ffmpeg frame bytes capture failed (code 183): ffmpeg version 7.1.4-0+deb13u1 … built with gcc 14 … configuration: --prefix=/usr --extra-version=` — identical on every install, and silent about why the capture failed. The exit code was the only usable information in the line. The banner-stripping summariser written for the camera streaming endpoint (#925) now lives in a shared module and every ffmpeg and ffprobe stderr goes through it: snapshot capture, last-frame extraction, the layer-timelapse stitch, the archive's MP4 conversion, external USB and RTSP capture and streaming, and timelapse post-processing. It drops the banner, keeps the last ten meaningful lines — the error plus the input analysis that explains it — and leaves indented `Duration:` and `Stream #0:0` lines alone, since those are diagnosis rather than boilerplate. **Two things the scattered copies also got wrong.** ffmpeg echoes its input URL back in its error output, and seven of those call sites logged it without masking, publishing a printer access code or an external camera's password into a log file people routinely attach to public issues; masking is now part of the summary, applied to the whole string before anything is dropped, so a credential straddling the cut cannot leave its tail behind. And ten sites called a bare `.decode()` on bytes ffmpeg had copied stream fragments into, which could raise `UnicodeDecodeError` while reporting an unrelated failure — the summariser takes bytes and decodes them safely. A failure whose output was banner only now says `no diagnostic output` rather than trailing off after the colon, and the whole line is capped, since ffmpeg quotes back what the peer sent it. 20 regression tests, including one that fails if a twelfth call site starts truncating or decoding by hand. - **Every print archived from a Bambu slice had no bed temperature, so preheat guessed one (#2989, reported by @senguendk)** — `PrintArchive.bed_temperature` was parsed by looking for a `bed_temperature` key in the project settings. BambuStudio does not write one: it stores a separate per-filament array for each plate type — `cool_plate_temp`, `eng_plate_temp`, `hot_plate_temp`, `textured_plate_temp`, `supertack_plate_temp` — and names the plate the project is sliced for in `curr_bed_type`. `bed_temperature` is the Orca/PrusaSlicer spelling, so the lookup matched nothing at all: **0 of 455 real 3MFs** on a live install produced a bed temperature, and every archive stored NULL. That is why preheat logged "archive has no bed_temperature metadata" on jobs sliced perfectly normally, and then heated the bed to the configured keep-warm temperature — 90°C by default — for prints that asked for 55°C. The extractor now reads the array the fitted plate points at, taking the first-layer value in preference since that is what the printer heats to before the print starts; the same 455 files now all resolve, across the four plate types they were sliced for. The plate names and the plate-to-key mapping are BambuStudio's own, so a plate a future release adds reads as unknown rather than silently taking another plate's temperature — as does `Default Plate`, which BambuStudio maps to no key. The per-filament array resolves to its highest entry rather than its first: the bed has one temperature and a 0 in that array means "this filament cannot print on this plate", so the first entry would record a cold bed for any project whose first filament is not one the fitted plate is heated for. An array that is all zeros is left unrecorded rather than stored as 0. Orca-exported 3MFs keep parsing through the generic spelling, which is now the fallback rather than the only thing tried. **Archives you already have are repaired too**: a one-shot pass at startup re-reads the 3MF still on disk for every archive whose bed temperature is blank, so preheat gets the right figure when you reprint an old job from the queue rather than only on new ones. It fills blanks and nothing else — a temperature already recorded is never overwritten, an archive whose file is gone stays blank, and a corrupted 3MF is skipped rather than failing the boot. The plate mapping is now shared between the ingest path and the repair so the two cannot read a 3MF differently. A dead copy of the old, wrong lookup that nothing called was removed at the same time, so it cannot be wired back up by accident. 36 regression tests. - **A print archived without its 3MF never got its timelapse, and on a short print could be given somebody else's (#2957 follow-up, reported by @doncaruana)** — The reporter confirmed the #2957 archive recovery worked and then noticed the timelapse was not recovered with it, "even though it's there". `_capture_timelapse_baseline_at_start` says in its own docstring that it must be called from every `on_print_start` path that proceeds to a real print, and what breaks otherwise: the completion scan falls back to snapshotting the card *after* the printer has written the video, so the new file lands inside the baseline and no diff can ever match. `on_print_start` has three such paths and the no-3MF fallback branch was not calling it — nothing else covered the gap either, because `on_print_running_observed` is restart-recovery only and is suppressed whenever `on_print_start` fires. So every fallback archive reached completion with no baseline in memory and none on the row, and kept its timelapse only by accident. Not confined to the reported cool-off case: the same branch serves the internal-storage verdict, so every H2C/H2D/P2S print that Bambu Studio's Print button sends to eMMC lost its timelapse the same way, on a card that was holding it the whole time. Measured on a live install before the fix: 0 of 9 fallback archives had a baseline, against 73 of 275 normal ones. The branch now takes the baseline like the other two, placed last as they are so a slow card cannot delay the active-print registration, the energy reading, the archive-created event or the start notification ahead of it. **The second half is the short-print case.** Under the five-minute FTPS cool-off the card is still unreadable when the baseline has to be taken, and `list_files_async` answers `[]` when its connect fails rather than raising — indistinguishable from a card holding no videos. When the cool-off then expired inside the 900-second poll window, every video on the card read as new, the first in listing order won, and a stale unclaimed video was attached to this print and deleted off the printer. The empty baseline is still recorded rather than refused, deliberately: Bambuddy deletes each video once it is attached, so the usual card holds exactly one video at completion and an empty baseline resolves it correctly — refusing outright would have lost that common case to protect a rare one. Instead the scan marks such a baseline untrusted and the attach step declines to *choose* between several candidates, leaving them on the printer for the manual Scan for Timelapse button. Five regression tests covering both halves and the single-video case; full backend suite green (11230). - **The print dialog named an AMS slot after the wrong spool** — The slot dropdown described every slot from the printer's own telemetry, and the printer cannot describe a spool it did not sell: a tray record has no brand field, `tray_sub_brands` is left empty for anything that is not a Bambu spool, and the colour is a bare hex the client resolves against Bambu's colour catalogue. A Devil Design PLA Basic Orange assigned in Bambuddy therefore read as "PLA (Sunflower Yellow)" — Bambu sell a Sunflower Yellow at the same `FEC600` — while the printer card, which reads the assignment, named it correctly. The two views now agree: `GET /printers/{id}/inventory-remain` carries each bound slot's brand, material, subtype, colour name and hex alongside the pooling key it already sent, and the dialog prefers that over telemetry, falling back field by field so a spool with no stored colour name still gets the catalogue lookup it had before, and re-reading it on every open so a spool assigned moments earlier is named correctly straight away. Resolved server-side, so internal inventory and Spoolman mode answer identically rather than the client re-deriving a rule that differs per mode. Matching is deliberately untouched and still runs on the printer's telemetry, so the auto-assignment and the colour-mismatch warning cannot start disagreeing with what the dispatcher does. - **Every page was a blank white screen on iOS 16.0-16.3 (#2971, reported by @zevulos)** — An iPhone on iOS 16 loaded nothing at all: no error, no partial render, just white, over LAN IP and over an HTTPS domain alike, while the same install was fine on Android, macOS, Windows and Linux. The cause was one regular expression. `remark-gfm`, added in v1.2.5 for the folder README panel, reaches `mdast-util-gfm-autolink-literal`, whose module body contains a lookbehind assertion — `(?<=` — that Safari did not support until **16.4**. A regex literal is validated when its module is *compiled*, not when the function holding it runs, so this was never going to fail as a broken README panel: `FolderReadmePanel` -> `FileManagerPage` -> `App` is a plain static import chain, the regex landed in the entry chunk, and the browser refused to compile all 10 MB of it. Nothing executed, so nothing rendered. Every install from v1.2.5 onward has been unusable on those iOS versions, and v1.2.4 is the last release that loads on them. **The fix.** The panel now renders GFM through a locally composed plugin that registers four of `remark-gfm`'s five sub-extensions — tables, strikethrough, task lists and footnotes — and omits autolink literals, which is the only one that carries the lookbehind. Composing rather than configuring is forced by the nature of the bug: importing `remark-gfm` at all is what breaks the page, so no runtime option could have reached it. The visible cost is that a bare `https://example.com` or `foo@example.com` typed into a folder README no longer turns itself into a link; `[text](url)` and `` are core markdown and still do. `remark-gfm`, `mdast-util-gfm` and `micromark-extension-gfm` leave the dependency tree, and the bundle is 23 KB smaller. **Why the build never said anything.** Vite's `build.target` governs syntax lowering, and esbuild does not rewrite regular expressions — measured here, a lookbehind builds silently under `safari15`, `safari16.0` and `es2020` alike, which is exactly how this shipped and then sat unnoticed for two months. So the guard is a real check rather than a compiler setting: `npm run build` now ends in `check-browser-baseline.mjs`, which scans the emitted bundles for syntax that Safari 16.0 cannot parse and fails the build with the offending snippet and the dependency-hunting command. It is deliberately scoped to *parse-time* failures only — a missing runtime API breaks one feature, while one of these takes down the whole app, and there is no graceful degradation to fall back on. Covered by seven renderer tests that pin both halves of the trade: each surviving GFM feature still renders, and the two forms of autolinking stay off on purpose so a future dependency bump cannot quietly bring the lookbehind back. - **A failure reason the backend derived and the same reason a user picked counted as two different reasons (#2974, reported by @ojimpo)** — `failure_reason` was written in three vocabularies and nothing reconciled them in storage. The backend wrote English display labels (`"Layer shift"`), older builds of the archive editor wrote the *translated* label in whatever locale that user was running, and two stale-archive paths wrote English prose sentences (`"Stale - reconciled after reconnect, end time unknown"`). All three land in one column — the PATCH route has mirrored the field onto the latest print-log entry since #1444 — and the Failure Analysis widget groups on the raw value, so one real cause occupied several buckets. Measured on a live install before this landed: 91 rows reading `"User cancelled"` beside 1 reading `"userCancelled"`. In an English UI those render as the same words twice with different counts, which is why it went unnoticed; in any other locale one of the two stays English, because a stored label has no key for `t()` to resolve. The editor was worse than cosmetic about it: its reverse lookup compared the stored value against `t(...)` in the *current* locale, so for a non-English user nothing matched, the dropdown opened empty over an archive that plainly showed a reason, and saving from that state wrote the empty selection over the stored text. **One vocabulary now.** The keys were already canonical and already enforced — `_FAILURE_REASON_KEYS` in `api/routes/print_log.py` rejects anything else with a 400, and says why in its own comment — so this is `derive_failure_reason` being brought in line with a rule the rest of the stack had been keeping. `_HMS_FAILURE_REASONS` stores keys, the cancel branch returns `userCancelled`, and both stale paths write a new `noStatusUpdate` key rather than prose; which of the two stale situations occurred is already carried by `status`, so collapsing them loses nothing and gives Statistics one bucket instead of two untranslatable sentences. **Existing rows are converted**, which was the open question on the issue: a startup migration folds 168 historical labels onto the 12 keys across both columns. It is exact rather than a guess — every label across all 14 locales resolves to exactly one key, verified with no collisions — and the map is a frozen snapshot rather than something read from the locale files at run time, because it maps what was written historically and regenerating it would silently stop recognising the very rows it exists to convert. A value outside the map is deliberately left alone; guessing at it would be worse than leaving one honest string in its own bucket. The migration carries no one-shot settings flag on purpose: it only matches values in the map and a key is never a label, so it is self-terminating, and a flag would permanently skip anyone restoring an older database. **The editor no longer destroys what it cannot read** — an unrecognised value keeps its own option in the dropdown and survives a save, instead of initialising to empty and overwriting the stored text. Covered by 13 migration cases across both tables, including the live 91-vs-1 split, both stale sentences, free text left untouched, and running twice changing nothing; plus a test asserting every value the backend can derive is a key the rest of the stack accepts, so a display label cannot creep back into the map the way it did before. New `noStatusUpdate` label translated in all 14 locales. - **Everything the internal slicer produced was Bambu green, whatever filament was picked (#2977, reported by @fadudba)** — A slice through Bambuddy's own slicer came out with `filament_colour = #00AE42` every time: a green plate thumbnail regardless of the profile chosen, and a **Color mismatch** in the Print dialog against the AMS slot the job had just been correctly mapped to. The reason is that a colour is not a property of a filament *preset* in either slicer — it belongs to the project, and their GUIs set it from the plate — so nothing was attached to the preset Bambuddy sent by name and the CLI fell back to its own compiled-in default, which is Bambu green. Verified against a 02.08.02.61 sidecar with the reporter's exact triplet: as sent, `['#00AE42']`; with a colour written onto the same profile, the colour asked for. Each filament row in the slice dialog now carries an editable **colour swatch**, and the colour reaches both `project_settings.config` and `slice_info.config`, which is what the thumbnail and the AMS mapping actually read. The swatch is pre-filled from the colour that slot was designed with — read from the source 3MF's own project settings — then the preset's `default_filament_colour`, then the slicer's green. It is offered on single-filament sources too, because an **STL, and equally a mesh-only 3MF exported from CAD, has no colour anywhere else to inherit**; measured, a colourless source records `color="#00AE42"` in its own slice info, so a fallback that read the *last sliced* colour rather than the *designed* one would have been circular. `default_filament_colour` is deliberately not treated as the answer on its own: the CLI never reads it — a profile carrying only that still slices green — it is consumed by the GUI when a project is created, so it is read and rewritten as `filament_colour`, which the CLI does honour. Bambu's bundled filament profiles define it nowhere at all (zero occurrences across the whole shipped tree), which is why it can only be one link in the chain. A slot the user did not touch and that has no designed colour submits an empty string rather than the swatch's displayed default, because a sent colour outranks the preset's own and pinning the placeholder would silently discard the real colour of an imported OrcaSlicer profile that carries one. Slicer Pipelines pick up the same chain without carrying a swatch of their own. The control sits beside the filament dropdown, styled like it and the same height, showing the swatch and its hex together. That placement is the third attempt and the first that reads as a control: a bare swatch in the label row looked exactly like the read-only dot multi-colour rows had carried for releases, and adding the hex beside it only made it look like a caption on the label — so on a single-filament STL, the one source with no colour to inherit and therefore the case the control exists for, nothing suggested anything was settable. The swatch and the hex are wrapped in one label bound to the input, so a click anywhere on it opens the picker rather than only a 16px dot being live. The colour is also painted on the input directly rather than relying only on the browser's native colour-swatch pseudo-element, since an unlit swatch is indistinguishable from no swatch at all. Translated in all 14 locales, wiki updated, and covered by 38 backend and 14 frontend regression tests. - **A filament preset the slicer could not resolve was sliced as PLA at 200 °C without saying so** — Found while investigating #2977. Bambuddy names the preset to inherit and the sidecar resolves it against its bundled profile tree; when that tree does not contain the name, nothing rejects it. The CLI inherits nothing, falls back to its compiled-in defaults for every field, and returns a well-formed success — so a PETG preset whose name a sidecar image predates prints at PLA temperatures with no diagnostic anywhere. Measured against a 02.08.02.61 sidecar: an unresolvable name slices as `filament_type ["PLA"]` at `nozzle_temperature ["200"]` with `filament_ids [""]` and `filament_vendor ["(Undefined)"]`. Bambuddy now recognises that pair and logs a warning naming the slot and the preset that was picked, pointing at the sidecar image as the fix. The file is **kept rather than refused**, unlike the missing start G-code of #2838: this one prints, it is only wrong, and someone may well be slicing deliberately with a profile their sidecar predates — so the choice is theirs to make with the temperatures in front of them. Both signals are required together, which is what keeps it from firing on the two legitimate cases that look similar: a hand-written profile that simply never named a vendor still carries a real filament id, and a user's own cloud preset carries a vendor while legitimately having no bundled id. - **An AMS slot card showed a multi-colour spool as one flat band (#2967, reported by @NeighborGeek)** — A Ziro "Colorful Mist" — yellow, cyan and pink, effect Tri Color — hovered on the printer card as a single pink rectangle, because a printer reports exactly one `tray_color` hex per tray and nothing else. Telemetry cannot describe a gradient or a surface effect and never will, so the header now paints the *spool's* own swatch whenever the bound spool carries extra colour stops or an effect, through the same builder the Inventory swatches use — the two surfaces cannot drift because there is one implementation. A plain single-colour spool keeps the flat colour it has always had, so the common case goes nowhere near the gradient path. Any stop at all counts, not just two: the colour layer ignores the base hex the moment stops exist, so a one-stop spool renders that stop rather than the slot's hex, and honouring it is what keeps the card agreeing with Inventory. The colour name and the print dialog's slot dropdown were the other two halves of the report and were already fixed on dev by #2875 and the slot-naming change that landed the day after it was filed. **Spoolman mode gained the gradient in the process**: Spoolman holds the extra stops in `multi_color_hexes` and the label renderer had been reading them for releases, but `_map_spoolman_spool` never returned them, so the identical roll registered in Spoolman rendered flat while the internally-managed one did not. Both now share one parser rather than reading the same field two ways. The asymmetry that remains is Spoolman's own and is pinned by a test rather than left to be rediscovered: it has no field for a surface effect — its only neighbouring field, `multi_color_direction`, describes how the stops are laid out, not that the roll is silk — so `effect_type` is None there instead of guessed at. The colour name moves onto the same scrim the vendor badge already uses once the background has more than one band, because a single hex cannot decide legibility across yellow, cyan and pink and the label sits dead centre where the background is likeliest to change under it; a single stop or an effect over one colour leaves a real base colour to test, and keeps the contrast rule it had. Wiki updated. 19 backend and 8 frontend regression tests. - **A printer card said "Unknown stage (72)" where it now says "Preparing"** — New models report stage numbers before Bambuddy learns their names, and the H2C still has several. Until now those reached the card verbatim, as a number that means nothing to the person reading it, on a line that otherwise names what the printer is doing. Every stage that has turned out to be unnamed so far has been part of the run-up to printing, so an unnamed one now reads as "Preparing" — the same label stage 74 already carries, rather than a second spelling of the same idea. The substitution is display-only and deliberately not pushed down into `get_stage_name`: that function also feeds the stage-transition log line and the once-per-session warning that exists precisely to capture unnamed stages so they can be named in a later release, and there the number is the entire diagnostic value. Both paths are pinned by tests that assert they disagree for an unnamed stage and agree for a named one, so a future tidy-up cannot quietly collapse them and blind the thing that reports these. The idle sentinels (255 on A1/P1, -1 on X1) still resolve to no stage at all rather than being swept up as unnamed. - **The internal slicer picked PETG for a PLA plate, and an A1 process for a P1S (#2982, reported by @Igiegel)** — Both traced to one line of the slicer sidecar: its bundled-profile listing read `filament_type` off the leaf preset and never reported `compatible_printers` at all. Measured against the shipped trees, the leaf read finds nothing — zero of the 1156 filament presets in OrcaSlicer 2.4.2 carry a material, and zero of the 1792 in BambuStudio 02.08.02.61 — because it lives one to four hops up the `inherits` chain (`Bambu ABS @BBL A1` → `Bambu ABS @base` → `fdm_filament_abs`). With no material on any of them the pre-pick had only colour and source to go on, so a white PLA plate drew `Bambu PETG Basic` on an A1 mini and `Bambu PC` on a P1S. The sidecar now walks the chain, which recovers the material for 1124 of the 1156 (the other 32 name a parent Bambu's own bundle does not contain, and stay listed with no material rather than being dropped). **`compatible_printers` is the second half**, and it is the only truthful account of which printer a preset belongs to: the bundle ships no process preset named after a P1S, an X1, an X1E or an H2D Pro — all ten of the P1S's are named `@BBL X1C` and name the P1S only in that list. Reading the printer out of the preset *name* therefore made a P1S look like it had no compatible process at all: all 198 were hidden behind “Show all” and the auto-pick fell through to an alphabetically-first `0.06mm Fine @BBL A1 0.2 nozzle` the CLI then refused with “the selected printer is not compatible with the process preset”. A P1S now gets the `0.20mm Standard @BBL X1C` it should always have had, and 73 filaments instead of 4. **This half needs the updated sidecar image** — an older one simply reports nothing and Bambuddy stays on the name matcher, degraded exactly as before rather than broken. Three hardenings ride along so a stale sidecar fails more gracefully: a filament preset that states a *different* material than the plate asks for is now skipped outright rather than merely scored down (a preset stating no material stays eligible — unknown is not wrong), a slot is no longer held on printer-compatibility alone once its material turns out to disagree (a preset you chose yourself is exempt — printing PETG on a plate labelled PLA is a legitimate thing to do), and a dropdown the printer filter would empty now shows everything instead, since a visible preset for the wrong printer can be changed and an empty list cannot. `filament_colour` stays null throughout, which is correct: no BBL profile carries a colour at any depth, because colour is a spool attribute rather than a profile one. - **Every slice that didn't name its own process quietly got the slowest one the slicer ships** — Found while tracing #2982. Within a tier the preset list is alphabetical, and Bambu's naming puts the finest layer height first, so the auto-pick landed on `0.08mm Extra Fine` for an X1 Carbon and `0.06mm Fine` for an A1 mini. Correct presets, but nobody's idea of a default. Among candidates that are equally valid for the selected printer, the one nearest 0.2mm now wins — `0.20mm Standard` where it exists, the closest thing to it otherwise, ties breaking toward the coarser and therefore faster height. A preset whose name carries no readable height is still pickable when it is the only candidate, and a process the 3MF named still overrides all of this. - **An H2D Pro classified every bundled preset as another printer's** — Also found while tracing #2982, and the same shape as the A1 mini's `A1M` rename (#1649): the bundle names H2D Pro presets `@BBL H2DP` while the printer preset, and the model registry with it, spells the model `H2D Pro`. The alias table now carries the pair. It stays deliberately narrow — `H2DP` and a plain `H2D` are still different machines and must not collapse. - **Spoolman reset your renamed extra fields on every restart (#2983, reported by @ngreatorex)** — Bambuddy checked whether one of its four custom spool fields existed by calling `GET /field/spool/{name}`. Spoolman has never served that: its API declares only `POST` and `DELETE` at that path, so the check answered **405 Method Not Allowed** every single time and could never succeed. Each call then fell through to `POST /field/spool/{name}` — and that endpoint is an *upsert*, not a create. It answers 200 whether or not the field is already there, so a field you had renamed, retyped or given a default to in Spoolman's own UI was silently reset to Bambuddy's version of it, and an untrue `Created Spoolman extra field` was logged alongside. The reporter's log carried 60 of those lines in three days. Existence now comes from the documented `GET /field/spool` listing, matched on the field's `key` rather than its display `name` — a rename is the same field, and treating it as a missing one is what caused the overwrite. An existing field is left completely alone. **You can now rename these fields in Spoolman and the name will stick.** Registering all four also costs one request instead of four, and none at all on a client that has already looked once. If the listing can't be read at all, Bambuddy falls back to attempting the write exactly as before, so an unexpected Spoolman build is no worse off than today. - **One ASA spool parked in the AMS added 20 minutes to every PLA print (#2886, reported by @FirstRulez)** — Preheat's chamber target was the maximum across *every loaded AMS tray*, with no reference to the job. The reporter's P2S holds PETG Pro, PLA, ASA and PETG; the ASA row of the filament map says 45°C, so a PLA-only plate was dispatched with `chamber_target=45°C`, the bed driven to 90°C to reach it, and the full 900s max-wait plus 300s soak burned before the upload even started — every time, because a P2S has no chamber heater and the chamber tops out around 33°C, so the wait can only ever end on the timeout. Their log carries fifteen of these. The intent was never in doubt: the resolution order documented one screen above reads "PLA-only print derives 0 → chamber phase auto-skips", but it was implemented as PLA-only **AMS** rather than PLA-only **print**, and only misfires on a mixed-material load. The derivation now reads the trays the item's `ams_mapping` actually names — the same array the print command puts on the wire, `[-1, -1, -1, 1]` in their case, addressing exactly the PLA slot — so the ASA two slots over contributes nothing and the stage skips outright. Multi-material prints are unaffected: the maximum is still taken, just across the trays the plate loads, so an ASA the print really does use is still the binding constraint. An item whose mapping is missing or still unresolved keeps the whole-unit scan, since that is the only signal left and narrowing to nothing would disable preheat for prints that need it. The bed hold between jobs (`queue_keep_bed_warm`) is gated on the same derivation and was holding beds at 90°C for the same wrong reason; it now reads the next item's mapping too. **The external spool is no longer invisible to this**: the scan only ever looked at `raw_data['ams']`, so an ASA print fed from the external feed derived 0 and got no preheat at all — a mapping that names 254/255 is now honoured, while an item with no mapping still derives from the AMS alone so nothing starts preheating that did not before. That covers the mappings Bambuddy builds itself, from the print dialog or the dispatcher's own matcher. It does not cover a mapping captured from a slicer through a Virtual Printer: BambuStudio writes the external spool as `-1` there, which is the same value it writes for a slot the plate does not use, so the two cannot be told apart. A wholly external one carries no usable mapping and falls back to the whole-unit scan as before; a mixed one derives from its AMS trays and the external half stays unread — which is exactly what it did before this change, since nothing ever read `vt_tray`. 29 regression tests, built from the trays and mapping in the reporter's own support bundle. - **A spool you assigned to an AMS slot unassigned itself seconds later (#2987, reported by @frethop)** — and the slot's colour changed at the same time. It looked like Bambu Studio and Bambuddy fighting over the slot; the reporter's log shows Bambuddy losing to itself. P1S firmware 01.10.00.00 reads every **lowercase** hex letter in an AMS `tray_color` as a zero, and hides it completely: the command response echoes back the value you sent and reports `result: "success"`, so only the next AMS push reveals what was really stored. The spool-assign path sent `spool.rgba` verbatim, and that column stores lowercase — so `09ff00ff` became `09000000` on the printer and `ff5100ff` became `00510000`, while the one uppercase write in the same window round-tripped intact. That is the visible colour change. It is also what deleted the assignment: the auto-unlink sweep asks whether the slot still matches the spool it is assigned to, the mangled colour no longer did, and the assignment Bambuddy had created four seconds earlier was removed. Re-assigning could not help, because the **Configure Slot** dialog seeds its colour from whatever the printer currently reports — so it wrote the mangled colour straight back and cemented it, which is the loop in the report's steps 4 and 5. Colours are now uppercased at the single point the MQTT command is assembled rather than in each of the four routes that configure a slot, because a caller that forgets is exactly how this arrived. Nothing else about the command changes: no padding, no invented alpha, and `tray_type` / `tray_sub_brands` keep their case, where it carries meaning. **Two more things found in the same log.** A spool with a brand but no subtype was configured with the literal string `None` in its name — `"Sunlu PLA Matte None"` went on the wire, because the branded branch interpolated the subtype without checking it while the unbranded branch guarded it. And the FTP log is now readable: a `426` whose bytes Bambuddy has verified against the printer is how Bambu's FTPS normally ends a transfer, not a fault, so it is logged at INFO instead of WARNING. It fired 54 times in this one bundle, every single one followed by a completed upload, and it was burying the 26 TLS handshake failures in the same log that actually cost the reporter two prints. A `426` whose bytes do **not** verify is still an error and still fails the upload. 24 regression tests. - **A manual K-profile calibration left a print in your archive** — Bambuddy already recognises the printer's automatic pressure-advance run, `auto_pa_line_calib_mode`, and skips archiving and notifying for it. Started by hand instead of automatically before a print, the same calibration reports under its own name with no `auto_` prefix — and manual flow dynamics has *two* shapes, a line and a pattern, so neither `pa_line_calib_mode` nor `pa_pattern_calib_mode` matched anything. Both arrive exactly the way the automatic one does — a bare subtask name with no `/usr/` path — which meant the archive path swept FTP for a 3MF that cannot exist and then wrote a no-3MF archive named after the calibration, on a printer that was in the middle of calibrating. Both are now on the same list, which is the one place both the print-start and print-complete callbacks consult. Matching stays exact after normalising path, suffix and case, so a file you deliberately named `pa_pattern_calib_mode_v2.3mf` — or your own `pa_bracket.3mf` — is still archived as the print it is. Wiki updated. - **A K profile could be saved against the wrong hotend, and applied to the wrong one** — Which nozzle an AMS slot feeds, and how wide it is, was worked out independently in seven places, each reading the printer's first nozzle entry for every slot on the machine. That is correct on a single-nozzle printer and on a dual-nozzle printer with matching nozzles, and wrong the moment two sizes are fitted: the K profile for the other hotend was looked up, and with the per-model presets above the wrong preset would have been too. The resolution now lives in one place, and which array entry belongs to which hotend is no longer inferred — measured on an H2D fitted with a 0.4 on the left and a 0.6 on the right, the first entry reads the **right** hotend, so the array is indexed by extruder id. Separately, the spool form identified a chosen calibration by `cali_idx` alone, and the printer numbers its calibration table **per nozzle** — on a dual-nozzle printer the same index exists on both hotends meaning different things, so saving could persist the other hotend's K value and nozzle diameter. Each hotend is now keyed by printer, extruder and diameter throughout, which also lets a 0.4 and a 0.6 profile for the same hotend coexist — something both K tables could always store but the picker could not express. SpoolBuddy's write-tag page carried a verbatim copy of the same lookup and gets the same fix. - **RFID auto-assign picked a K profile without checking which hotend it was calibrated on** — The first stored profile matching the printer and nozzle size won outright, with no extruder test at all. On a dual-nozzle printer a spool calibrated on both hotends therefore had a coin toss decide which pressure-advance value the slot got, on the path that runs unattended every time a Bambu spool is loaded. - **Linking a Spoolman spool by tag configured the slot as generic filament** — That path resolved no slicer preset whatsoever and went straight to the generic material id, so a Spoolman spool with a preset set in inventory lost it the moment it was linked by tag. The same defect #1713 fixed on the assign path, in the function next door. - **Moving an AMS to the other nozzle re-selected the K profile for the wrong one** — When a Filament Track Switch moves an AMS between inlets, the slot's K profile is re-selected for the nozzle it now feeds. It was resolved against the printer's first nozzle rather than the one the AMS had just been moved to, which on a machine with two different sizes fitted is the wrong nozzle by construction. - **Reading a printer's calibration table could stall for 20 seconds** — H2-series firmware answers only the first one or two of a concurrent burst of calibration requests and silently drops the rest, each dropped request costing a five-second timeout before its retry: measured at 11 and 23 seconds on an H2C and an H2D for four parallel requests, against roughly one second sent in series. Sizes are now requested one at a time, while the printers themselves are read in parallel since separate machines are separate connections. An X1C answers all four at once, which is why this only ever surfaced on dual-diameter printers. - **A batch order whose queued runs were deleted became a card that could neither be queued nor closed (#2960)** — An order queues the runs it owes by cloning an existing queue item for the same plate: that row is the only record of the printer target, AMS mapping, filament overrides and print options the user chose, and there is nothing else to copy them from. Deleting it left the order still reporting the run as outstanding, with **Queue remaining** answering "Plate 1 has no queued or finished run to copy settings from" every time, and no way out — the Cancel action was hidden unless the order had pending items to cancel, which by then it had none of. Queue a multi-plate file, change your mind, remove the rows, and the Batches tab kept a card that did nothing forever. Deleting an order's last surviving run for a plate now cancels it instead: a cancelled run does not satisfy a target, so the order still says it owes the print, and it can still produce it. That is the design the feature already documented for cancelled runs — the delete path simply never took part in it. A run that has *completed* is exempt and is still deleted outright, because rewriting a finished run as cancelled would falsify what the order actually produced, and a plate with any other surviving run is untouched, so this only ever engages on the last one. The queue and the Clear History action both say what happened rather than reporting a delete that did not occur. Independently of that, the card no longer offers actions that cannot work: the response now reports, per plate, whether anything remains to clone from, so a stranded plate explains itself instead of presenting a button whose only outcome is an error toast, and the header button offers only the runs that can actually be queued. Dispatching an order with one stranded plate now queues every other plate instead of aborting the whole order on the first one it cannot clone — an explicit single-plate request still fails loudly, and names the plate the way the Batches tab does rather than by a bare index. Cancel is offered for any active order, since closing one out is exactly what an order with nothing left pending needs. Existing stuck orders are not rewritten: they report honestly that their runs cannot be re-queued, and Cancel now closes them. Translated in all 13 locales, with backend and frontend regression tests including the delete-then-dispatch path this began as. - **Picking a spool near the bottom of the label dialog could scroll the dialog itself out of view (#2918, found and fixed by @whitigol)** — The modal panel was `overflow-hidden` under a 90vh cap, which makes it a scroll container even though nothing was meant to scroll there. When a checkbox low in the spool list took focus the browser scrolled every scrollable ancestor to bring it into view, and the panel obliged by scrolling its own header and print buttons off the screen. It now clips instead of hiding — the same clipping, but not scrollable — so the only thing that moves is the spool list, which is the part that is supposed to. - **A fully transparent spool printed a label with no QR code (#2918, found and fixed by @whitigol)** — The colour swatch is drawn from the spool's RGBA, and an alpha below opaque puts the PDF into a transparency state that was never turned off again. The QR code is drawn immediately after the swatch, before any other colour is set, so it inherited that alpha: a spool saved at alpha 0 produced a label whose deep-link QR was invisible, and therefore unscannable, while the rest of the label looked correct. The swatch now draws inside a saved graphics state so its transparency ends with it. - **The K value on an AMS slot card went blank after a while, and came back after a backend restart (#2854)** — H2-series trays carry no K value of their own; they report a calibration index, and the number on the card is resolved from that against the printer's calibration table. The printer answers that query per nozzle diameter, and it answers whoever asks — BambuStudio's queries arrive on the same topic Bambuddy listens to — but every response was stored as though it were the whole printer's table. The nightly GitHub backup asks for 0.2, 0.4, 0.6 and 0.8 in turn and finishes on 0.8, which holds nothing on a 0.4-plus-0.6 machine, so the table was left empty and every K value on the card disappeared until something happened to refill it. Responses are now filed under the nozzle they describe, so an empty answer for a size the printer does not have clears only that size. The same change fixes the three spool-assignment paths that look a calibration index up by nozzle diameter and had been quietly finding nothing whenever the last response was for a different nozzle. - **A slot could show the other nozzle's K value, and a nozzle that had been swapped out could keep showing its own** — Calibration indices are numbered per nozzle, so index 16 exists on each and means something different on each. The card's WebSocket updates keyed on the index alone, so whichever nozzle's table was read last won the slot; only the first render, which comes from a different code path, got it right. Both now resolve a slot through the extruder it feeds, and fall back to which nozzles are actually fitted when that is not enough to single one out. Where neither settles it the card shows nothing rather than a confident wrong number. - **A fresh Bambuddy showed no K values at all until someone opened the Profiles page** — Nothing read the calibration table when a printer connected. It arrived by luck: a visit to Profiles or Configure Slot, a GitHub backup, or the printer answering somebody else's query. It is now read once per connection, asking only for the nozzle sizes actually fitted. - **Turning Spoolman mode on deleted every built-in slot assignment, and turning it back off did not restore them (#2812, reported by @chitrangdesign)** — The toggle ran an unfiltered `delete(SpoolAssignment)` across every printer. Switching straight back cleared the *other* table instead, so the two directions were symmetric in code and one-way in effect, and the setting auto-saves on a 500 ms debounce with no save button and no confirmation. Opening the settings page to see what the option did was enough to destroy the configuration — the reporter's log shows four toggles in 85 seconds and the assignments never came back. The deletion had a real reason: checks that read both assignment tables would otherwise let a row in the mode you are *not* using answer for the mode you are, which is how #1473 was fixed. That reason has moved to where it belongs: every place that could be confused now asks which mode is active rather than reading whichever table happens to have rows, and nothing is deleted on a toggle. Each mode keeps its own assignments, so switching is reversible and inspecting a mode costs nothing. Six sites needed it, and only two are reads you would guess at. The per-slot K-profile lookup consults the built-in table first and, on a hit with no matching profile, deliberately stops rather than falling through to Spoolman, so a leftover row would have shadowed the Spoolman binding — the symptom #1556 reported from the other direction. Configuring a slot *writes* its K-profile against whichever table answers first, so the same leftover would have filed a calibration against a spool the printer is not using and never written the local one, leaving a calibration that appeared to succeed and then did not apply. And the auto-unlink pass that drops an assignment whose tray no longer matches its fingerprint ends in a delete: left ungated it would have removed the preserved rows one slot at a time as the AMS contents changed under the other mode, undoing the whole point of this more slowly but just as completely. The remaining two are the missing-assignment notification and the queue cost estimate. A sixth, the built-in remaining-weight fallback inside the Spoolman AMS sync, is deliberately left inert: it was unreachable while the table was being emptied, it is keyed by slot rather than by spool, and `create_spool` writes `remaining_weight` unconditionally, so waking it up would seed a stale figure into a brand new Spoolman spool. Existing installs need no migration: their inactive table is already empty, because it was being emptied. - **A print that could not debit a spool said nothing about it (#2812)** — This is what turned the toggle above from an annoyance into lost filament. A print whose assignments existed at print start and were gone by the time it finished resolved its 3MF, read its per-filament grams, resolved its tray, and then skipped the debit because the row no longer existed — logged at INFO, invisible under the default log level, with the completion notification firing as usual. 65.49 g was never deducted and the reporter only noticed because a spool's remaining weight looked wrong. The skip is now a warning that names the grams, and a completed print that failed to charge a tray it drew from raises the missing-spool-assignment notification, not only the print-start check that runs before the job and was correct to stay quiet. The two are different statements — the first says the weight may not be tracked, the second says it was not — so a print warned at start may notify twice, which is the right trade. This is independent of the toggle and catches any other cause of an assignment disappearing mid-print. - **Print cost ignored the linked Spoolman spool's price and always used the default rate (#2591, reported by @khaosdoctor)** — Spoolman holds per-spool pricing, and #261 gave that as the reason for integrating with it, but nothing ever read it. A print's cost is set once, at archive time, from the built-in Filament catalogue matched on the *primary* type, falling back to a global default rate — and in Spoolman mode nothing revisited that figure afterwards. The per-spool recompute that would have fixed it runs only over rows the built-in inventory writes, and Spoolman mode hands the usage tracker `spoolman_owns_usage` at print start so it writes none. On an install with an empty catalogue, which is what the reporter had, every print came out at the default no matter what the spool cost. Multi-material was wrong twice over: the primary type's rate was applied to the whole print's weight, so a slot of expensive PA was billed at the price of the PLA beside it. Each slot is now priced from the spool it was actually charged to, as the charge is made, and the per-slot costs are summed — which is what fixes the multi-material case rather than a separate change. The rate is the spool's own `price` when set, else the filament's, divided by `filament.weight`; that is the same net-grams field the remain%-delta path already divides by, so a spool that can be charged by percentage can always be priced. A missing or zero price is read as unpriced rather than free, and those grams — along with a tray that has no Spoolman row and any filament the sliced file never attributed — are covered at the default rate in one subtraction against the archive's own total, so a partially priced print still reports a whole-print figure instead of a fraction of one (#1344 in the other inventory mode). Only the first run writes the archive's cost, matching the built-in writer (#1378). Pricing is applied even when the slot-to-tray mapping was a positional guess, unlike the colour and material rewrites beside it: those overwrite what the slicer recorded, whereas the grams have already been deducted from these spools and the archive should say what that deduction was worth. - **A rescan or a cost recalculation quietly replaced a Spoolman-derived cost with a default-rate one** — Both rebuild an archive's cost from the built-in inventory's usage rows and fall back to the catalogue or the global default when there are none. In Spoolman mode there are never any, so the fallback was not a recalculation but a downgrade, and it would have undone the pricing above on the next rescan. The spool-to-slot resolution a price is derived from only exists while a print is being completed and cannot be rebuilt from the archive row afterwards, so both now leave a cost alone rather than replacing it with a worse one, and the bulk endpoint reports how many it kept. An archive that has no cost yet is still priced, and with Spoolman off both behave exactly as before. - **A print sent from Bambu Studio charged the wrong Spoolman spool, and rewrote the archive to match (#2953, reported by @bitelvl1)** — A sliced file numbers its filaments 1..4; which AMS tray each came from is decided when the job is sent, and #2768 gave the Spoolman writer two ways to recover that decision when the print did not come through Bambuddy: the printer's own `mapping` field, and a colour match of the 3MF's slots against the loaded trays. An A1 can satisfy neither. It publishes no `mapping` field, and it drops the MQTT connection when Bambuddy subscribes to its request topic, so the slicer's instruction never arrives either — which leaves the colour match, and that compares hex strings exactly. The reporter sliced with a generic black profile against a tray they had set to #111111. No match, so every print fell through to the positional default and charged slot 1 to whatever sat in the first tray: 2.17 g onto a grey PLA+ spool, while the print was fed from tray 3. The printer had already said so. `Tray change during print: tray=3 at layer=0` is in their log, recorded 90 seconds in, and read further down the same completion pass to decide which slots the print had touched — the same pass then charged tray 0 on a guess. A single-slot print is now pinned to the tray the printer reported feeding from, using the ladder the built-in inventory writer has consulted all along: the mid-print tray-change log, then the tray loaded at print start, then the current one, then the last real tray seen. Spoolman users were the only ones not getting it, which is why an install with the internal inventory in use never showed this. Gated on exactly one slot with usage, because a multi-colour print moves `tray_now` on every change and one reading cannot then be attributed to one slot; and it declines when the log holds more than one switch, so an AMS-backup runout still splits per segment (#1793) rather than landing whole on one spool. Where nothing at all names a tray the positional default still stands — it is right for an AMS loaded in slicer order — but it now says so at warning level, and no longer restamps the archive's filament colour and material from a spool it picked by position. That restamp is what made the fault look like data loss: the grams can be put back, whereas overwriting what the slicer recorded leaves nothing to compare against, and the reporter's archive had already been rewritten from #000000 to the wrong spool's grey. - **One unexplained disconnect could stop a printer reporting slicer print mappings for good** — Bambuddy subscribes to the printer's request topic to intercept the mapping a slicer sends with a print. Printers that refuse kill the TCP connection instead of returning a SUBACK failure, so the only signal is "we subscribed, then got disconnected", and that was believed the first time it happened. Every other reason a connection drops inside the same few seconds looks identical — a network blip, the printer rebooting, the container being stopped mid-probe — and the verdict was cached per serial with no re-probe anywhere, so on a printer that supports the topic perfectly well a single unlucky drop cost mapping capture for the rest of the process, and every Studio print after it was charged to a spool picked by tray position. The subscription is now retried once before being written off, and a disconnect Bambuddy asked for is not counted at all. A printer that genuinely refuses answers the same way every time and pays one extra reconnect; nothing changes for one already known to refuse, which still skips the subscription outright rather than reopening a reconnect loop. - **The AMS temperature alarm fired hourly on ambient room heat, and silencing it cost the colour band (#2905, reported and contributed by @ojimpo in #2943)** — `ams_temp_fair` decides when the AMS card turns amber, and it decided when a notification was sent as well. 35 °C is a reasonable place to change a colour and a low place to page someone: an AMS in a room without air conditioning sits above it on ambient heat alone, so the alarm repeated every hour for as long as the weather lasted — with every heater target at zero and humidity inside the good band — and the only way to stop it was to raise the display band and lose the red that says the unit is warm. "Red above 35 °C, notify above 45 °C" was not expressible. A new **Alarm above** field under Settings → AMS Display Thresholds separates the two. It is nullable and unset resolves to the fair value, so an install that never touches it behaves exactly as it did and there is no migration — the field is a row in the existing key/value settings table, not a column. Deliberately not a per-filament map like the humidity thresholds: that map exists because humidity also decides when a drying cycle starts and PLA, PETG and PA want different targets, whereas ambient heat does not care what is loaded, so a per-type map would only help someone who set every type to the same number. Three call sites take the new value, not one. The comparison is the obvious one; the second is the drying latch, which releases once the unit reads back at or below the threshold it was given, so handing it the display band stranded the latch on any unit that settles above it — an AMS resting at 37.7 °C never returns under a 35 °C band, so the hold placed during a cycle could only expire on its grace cap instead of releasing when the unit had actually cooled. The third is the number the notification quotes, or the message reads "> 35 °C" while firing at 45. A non-positive or non-finite value is refused rather than honoured, which matters more than it looks: nothing is ever greater than NaN, so a value that failed to parse as a real number would have silenced the alarm permanently — the failure mode that looks exactly like a working configuration. The field shows the fair threshold as its placeholder so the fallback is visible without reading docs, says so when a value it cannot use is entered, and the temperature block gains the disclosure line the humidity block has had all along: only the alarm threshold notifies, Good and Fair colour the display, and leaving it empty alarms where it always did. The background task that dispatches these alarms is a no-arg infinite loop and had never been covered by a test; it is now driven end to end, pinning that an install with no value set still fires and quotes 35, that a stored 45 is both what fires and what the message says, and that 37.7 °C in a warm room sends nothing. - **A clear spool synced to Spoolman as pure black (#2912, reported and contributed by @ojimpo in #2924)** — The AMS reports a translucent roll as `00000000`, and every write to Spoolman truncated that to six characters before storing it, so a PETG Translucent spool arrived as opaque black and the external catalogue then named it "Black". Spoolman's own schema accepts eight characters, so the value Bambuddy was discarding was one the backend would have taken verbatim. #1545 fixed exactly this for the built-in inventory and left the Spoolman path behind, which is why internal mode has been storing the alpha correctly for months. The read side was the matching half: it rejected anything that was not exactly six characters, so fixing the writes alone would have turned clear spools grey instead of black. Eight characters are stored only when the alpha byte says the filament is genuinely translucent — passing everything through would rewrite the colour of every opaque spool on its next touch, churning records in people's Spoolman for no benefit. Colour comparisons now key on the shape a value would be stored as, so two colours match exactly when storing them would produce the same value. That is what keeps the widening safe in both directions: an opaque tray still finds the six-character filaments every existing instance is full of, so no upgrade mints a duplicate for every spool on the next sync, while a clear roll gets its own record instead of being conflated with the black one of the same RGB. The edit route compares the same way, so a no-op edit no longer PATCHes the filament on every save and an alpha-only edit still reaches Spoolman. One consequence is worth stating: a filament already stored wrongly-opaque by this bug gets a second, correct record the next time that roll is auto-added, rather than the old one silently capturing every clear spool that follows. - **Translucent spools showed as an empty circle or as solid black in four more places** — The swatch helper had two answers, the transparency checkerboard for a fully clear colour and a flat fill for everything else, so a half-translucent spool rendered identically to an opaque one. The AMS tray swatches never reached that helper at all: Assign Spool painted the reported colour directly, so a clear tray was an invisible circle, and Configure AMS Slot cut the alpha off first, so a clear tray was solid black — the same symptom as the sync bug above, in the UI, and present regardless of which inventory mode is in use. All of them now draw through one helper, which lays a partly translucent colour over the checkerboard so the swatch shows both the tint and that it is see-through. - **Every notification provider vanished from the list after the inventory toggles were wired up** — Adding `on_stock_reorder_alert` and `on_stock_break_alert` to the provider schema made them required on the way out as well as the way in, because the response model inherits the write model. Every `on_*` column on `notification_providers` is nullable with no server default, and on an install where the table had already been created from the ORM metadata before migrations ran, the `ALTER ... DEFAULT false` that introduced those two columns was swallowed as a duplicate and never backfilled the rows that were already there. Those NULLs sat harmless for as long as nothing read them; the moment the flags were declared on the response, the row failed validation, and since a list is validated as a whole, one such row took every provider down with it. The API returned a 500 and the UI rendered what it was given — an empty list — so correctly configured providers looked deleted while sitting untouched in the database. They are backfilled to off on the next start, matching what the sender already did with them: it selects providers with `IS TRUE`, so a NULL flag never sent anything. A NULL flag now also reads as off rather than failing the response, so the next flag added to that schema cannot repeat this. - **Two inventory notification toggles could never be turned on, so stock alerts have never been able to fire** — `on_stock_reorder_alert` and `on_stock_break_alert` exist as columns on a notification provider, have their own templates, and `notification_service` looks providers up under exactly those names before sending. The whole UI is there too: a toggle in Add/Edit Notification, a badge on the provider card, the field in the API client's types, and tests for all of it. The one thing missing was the schema. `NotificationProviderCreate`/`Update` never declared either field, and Pydantic drops what it does not declare, so every request that carried them came back `200 OK` with the row unchanged — and `_provider_to_dict`, which is a hand-maintained field-by-field map, never returned them either, so the toggle read back off no matter what the database held. Nothing errored anywhere along that path. Both directions are wired now, and the round-trip tests that already covered the Home Assistant toggles cover these too, because the failure is structural rather than particular to one field: any column missing from those two maps is invisible to a test that builds providers through the ORM, and only a create-then-re-read through the route catches it. This makes the setting stick and report itself honestly; the detection side that would *call* those two senders does not exist yet, so turning them on does not yet produce notifications. - **One Home Assistant sensor reporting a long text state could stop every printer sensor from updating** — `last_state` is a 64-character column, and the poller wrote whatever Home Assistant returned straight into it. A numeric entity that starts answering with free text - an enum, an error string from a template sensor - overflows that. SQLite stores it regardless, which is why this stayed quiet, but PostgreSQL rejects the row, and a poll pass commits every sensor at once: one such entity took the whole batch down on every tick, so no printer sensor's reading, timestamp or alert state advanced again, and the print interlock kept deciding against a frozen picture. What is persisted is now cut to the column, while the cached reading keeps the full state for display. The comparison that decides whether the state changed is made against the cut form too - comparing the stored value against the raw one would read as a difference on every single poll and churn `last_changed` forever. The storage-location poller was fixed the same way in the same release; both now go through one helper, each passing its own table's width. - **Configure Slot could bind the default K value for a profile the picker was visibly showing** — The AMS slot dialog sends `cali_idx` from `selectedKProfile`, which the mutation read through its own closure. React Query hands a mutation its options from an *effect*, so a click landing between a commit and that effect flushing runs the previous render's function - one that captured the selection as it was before the K-profile query resolved. The result is `cali_idx: -1`: the printer binds the default 0.020 rather than the calibrated K, while the dialog shows the right profile selected the whole time. It surfaced as an intermittent failure of the per-nozzle K-profile test, roughly one full-suite run in six, and reproducing it with staggered query resolution showed the divergence directly - the select element held the correct profile immediately before and after the click, and the payload still carried -1. That test's slot is the most exposed case in the file, a right-hotend slot carrying the left hotend's index, where the "keep showing the active profile" safety net cannot repair an empty recompute. The mutation now reads the selection from a ref written during render, so it resolves at execute time rather than at capture time; the same applies to the K value and the profile's ids, which travel in the same payload and had the same exposure. Measured over 27 runs of a staggered-resolution grid: 2 failures in 15 before, 0 in 12 after. The modal's printer-model query was also missing from the test file's mock, so it ran with no query function and rejected on every test in it - mocked now, though on its own that changed nothing, which is how the ref was confirmed as the fix rather than assumed. - **A print started from the printer's own screen swept every FTP path, archived blank, and then blamed a slicer setting (#1820, reported, captured and re-measured across four daily builds by @ojimpo)** — `current_project_url` was assigned in exactly one place, `_handle_request_message`, and that runs only for the request topic. A print started from the touchscreen publishes nothing there, so the field stayed empty for the one case the storage verdict exists for: the file is already in the printer's own model library under `/userdata/model/history/`, and port 990 does not serve it. The verdict then fell through to the `sdcard` flag — which @ojimpo's H2S reports as true, its "card" being the internal eMMC — so every such print ran the full sweep before giving up: 16 filename-and-directory attempts over 22 FTPS connections, 18 of them refused, 6.4 seconds, then a fallback archive with a name and nothing else. The printer does announce where the file lives. It arrives as an unsolicited `project_file` **response** on the report topic about two seconds before `gcode_state` reaches PREPARE, and Bambuddy now reads the `url` off it, gated on `result: SUCCESS` and a non-empty value so a refused dispatch cannot name a file that was never written. Reading it there rather than only at the request topic also covers an install nobody had in view: some brokers refuse the request-topic subscription, and on those no print of any kind had ever populated the field. Our own dispatch is echoed on both topics, so the new branch captures state and nothing else — the "external dispatch" diagnostic stays with the request-topic handler, which sees the echo first, and reusing it here would have logged every Bambuddy-started print as somebody else's. What the print names is now what gets tried: the five directories a copy could be in, rather than the ~110 connections that cannot succeed. That copy is worth trying, because an H2S keeps recently used jobs under `/cache` and archives them in full while they last; roughly eight files later the same job archives with a name only, which is exactly what @ojimpo measured on two prints the same day. Nothing about slicer-sent prints changed. **The banner that appears afterwards no longer describes a step that never happened.** With no reason recorded, a blank archive fell back to the original wording — "Store sent files on external storage" is off in your slicer, go and turn it on. On the reporter's printer that setting is on, and the internal-storage wording added in #2780 already explains why it would not help on an H2 anyway, so the archive that most needed that explanation was the only one that could not be given it. Prints started from the screen, from Handy, or by picking a file the printer already had now carry a reason of their own and their own wording: no slicer was involved, nothing was sent, and no setting changes it. What can be done instead is offered in its place — start the print from Bambuddy, or read the sliced weight off the printer's own file browser and enter it under **Filament used (g)** in Edit Archive, which the cost and the Projects totals then follow. Settings > Printers > Connection Diagnostic reads the same reason rather than a fixed one, so the two surfaces cannot end up giving the same printer different advice. - **A print that started during an FTPS pause was archived empty forever, even after Bambuddy downloaded the file (#2957, reported and diagnosed by @doncaruana)** — A failed TLS handshake puts a printer's file service in a five-minute pause, and the archive flow checks that pause at the top of its path loop and gives up before opening a connection. @doncaruana's P1S started a print inside one: Bambuddy wrote an empty fallback archive 13 milliseconds after print start, having never touched the network. Four minutes later the pause cleared, the cover endpoint downloaded the very same file — all 8,956,942 bytes — parsed it, took the plate-2 thumbnail out of it, and published it to the shared 3MF cache under the exact key the archive flow looks up. Nothing ever looked. All three readers of that cache run before or during the print-start handler that had already given up, and print completion drops the cache as its first act, deleting the file. So the archive stayed a shell — no thumbnail, no file size, no filament, no layers — for a print whose source Bambuddy had held, parsed and indexed, and there was no path in the codebase by which a fallback archive could ever become a real one. Two things change. A 3MF that arrives later is now offered to the running print's archive, which fills the existing row in place rather than adding a second one — the row's id is load-bearing, with the energy reading, the timelapse session and the start notification all written against it. That covers this report at no network cost, because opening the printer card already downloads the file to draw its thumbnail. And a fallback created *because of* the pause now schedules its own bounded retry, spending the cache first and the printer only if that misses. The retry is deliberately not scheduled for the other reason an archive comes up empty: a print the printer kept on internal eMMC has no FTPS copy to come back for, and retrying it is the sweep that was removed in #2780 — so the two causes are now recorded separately instead of both landing as "no 3MF". Recovery refuses anything that is not a readable 3MF, since a truncated download would replace an honest empty archive with wrong metadata, and leaves an archive alone once it has a real file. The last opportunity is taken too: print completion spends the cache on a still-empty archive before evicting it. Wiki updated. - **A printer whose failure detection was not working showed a green "Safe" badge (#2952, reported via in-app bug report, with a full ML API and Bambuddy log correlation)** — The printer card's AI badge collapsed every class that was not Warning or Failure into green **Safe**, and the service reported `safe` whenever it had no verdict to give. The state entry is created the moment a monitored print is seen, which is before the first snapshot and well before the first inference, so a rejected ML API token, an unreachable ML API, a camera that never yielded a frame and an unset External URL all rendered identically to a healthy watched print: a green pill reading Safe at score 0.000. For a safety feature that is the worst available failure mode — it asserts the print is being watched at exactly the moment it is not. The reporter read that badge, concluded the detection loop had never started, and spent an evening ruling out the network path; the loop had in fact been calling the ML API every ten seconds the whole time and being turned away with a 401. What hid it is that Obico's auth layer rejects a bad token before its own request log records anything, so a token problem and a service that never runs leave the same trace in the ML container: nothing. Successful checks log nothing there either, which leaves the container log unable to distinguish working from broken in either direction. The badge now has two more states and tells the truth in both: **Not checking** (amber) when the last check produced no result, carrying the reason — rejected token, unreachable API, failed capture, missing External URL — in the tooltip and the modal, and **Starting** while a monitored print waits for its first result. Score and frame count are withheld while a printer is not being checked, since 0.000 next to "Not checking" reads as a measurement rather than the absence of one. The reason is tracked per printer rather than service-wide, so on a multi-printer setup a card names its own problem instead of whichever printer failed most recently; it stays behind `settings:read` because it can quote configured URLs, while the badge state itself does not, because whether a print is being watched is not configuration. An unrecognised class from a future backend now falls back to **Starting** rather than **Safe** — the fallback that caused this. Also fixed alongside: **Test Connection** now saves the form before probing, so a green "reachable and healthy" always describes the configuration the detection loop is actually running with rather than what is typed in the boxes. The wiki's badge table and troubleshooting section are updated, including the ML API container log being unable to answer this question. - **Bambuddy could not start against a PostgreSQL server whose messages are not in English (#2949, reported and diagnosed by @dvb6666)** — @dvb6666's Debian PostgreSQL 15 answered the first migration statement with `столбец "parent_run_id" отношения "pipeline_runs" уже существует` and startup aborted. The column already existing is the *expected* outcome there: `create_all()` builds the tables from the models before the migration list runs, so on a fresh database essentially every `ADD COLUMN` in that list is a duplicate by design, and all 382 of them relied on the runner recognising "already applied" and moving on. It recognised it by searching the error text for `already exists` — but PostgreSQL renders its messages in the server's own `lc_messages` locale, and a Russian server does not say that. So the very first statement was re-raised as a fatal error. This was never specific to `parent_run_id` or to one PostgreSQL version, which is why @dvb6666 got the identical failure after moving from 15 to 18: no PostgreSQL server outside an English locale could start Bambuddy at all, fresh install or upgrade, and the failure surfaced as a schema error rather than as anything pointing at the language. Idempotency is now decided by SQLSTATE, the five-character code PostgreSQL never translates — `42701` duplicate column, `42P07` duplicate table or index, `42710` duplicate constraint or trigger, `23505` duplicate key. The narrowing that was already there is kept and now rests on a code rather than a phrase: a missing column counts as "already applied" only for `RENAME COLUMN`, so a column missing during `ADD COLUMN` or `CREATE INDEX` still aborts startup instead of quietly hiding a corrupt schema — as do a missing table, an unknown column type, and a syntax error, each verified against a live server. SQLite keeps the text match, since its driver publishes no SQLSTATE and it never localises its messages. The same treatment went to the second place that read message text, the OIDC auto-link safety constraint, where a mistranslated "already exists" would have failed startup on the same servers. Measured rather than reasoned about: against PostgreSQL 15 under `ru_RU`, `en_US` and `C`, a full `init_db()` completes on both a fresh database and a re-run, and the schema the Russian server ends up with is byte-identical to the English one — nothing is being silently skipped to make startup succeed. - **A test in the plug-energy suite failed for 31 minutes of every day (#2938, reported and measured by @ojimpo)** — `test_nothing_derivable_before_the_first_midnight` passed for 23.5 hours a day and failed for the other half hour, which is the shape that reads as ordinary flakiness and gets re-run rather than fixed. @ojimpo hit it running the full suite for an unrelated PR at 22:10 UTC, stashed his branch to confirm it reproduced on clean `dev`, then measured the window minute by minute against the real day boundary instead of guessing at it. **The test's premise, not the product.** It asserts that nothing can be derived when a plug's only snapshot was taken *after* this local midnight, and it placed that snapshot at a raw wall-clock offset — `now - 30 minutes`. The comment beside it, "taken this morning, after midnight", is the premise, and it is only true away from the boundary: for the first half hour of each local day, `now - 30 minutes` lands *before* local midnight, where it is a perfectly good baseline. `_counter_at` finds it, and `today` comes back `103.5 - 102.0 = 1.5` instead of `None`. The window is local 00:00–00:30, which is 22:00–22:30 UTC while CEST is in effect and 23:00–23:30 under CET — it moves with DST, because the module pins `Europe/Berlin` in an autouse fixture and an outer `TZ` makes no difference to it. **`derive_today_yesterday` is right and unchanged.** A snapshot from before local midnight genuinely *is* a valid baseline for today, and the production code treating it as one is the documented behaviour, so nothing here reaches a running install. The snapshot is now anchored to `local_day_start(now) + 30 minutes`, which is the idiom the file's other nine snapshot writes already use and the reason none of *them* could drift — this was the only one that offset from the wall clock, and the only one that failed. Verified by replaying 5760 minutes across four days including both DST switch days: the old expression fails 31 minutes per day, the new one fails none. **Scope.** One test file. No product code, no DB migration, no schema change, no permission, no i18n change. - **The slice dialog took settings from the file with "Use the file's built-in settings" switched off (#2942, reported by @zevulos)** — Two separate features read as one. The checkbox slices a 3MF the way its designer set it up, ignoring the picked profiles; the per-option "from file" ticks beside each setting carry the designer's individual deviations onto the profile you picked (#2622), and those arrived pre-ticked whatever the checkbox said. So a slice run deliberately *without* the file's settings still took sixteen values out of it — the reporter's own log names them, `enable_support` and `support_type` among them, landing on a process preset they had chosen on purpose. The ticks now follow the checkbox: off, nothing comes out of the file until it is asked for by name; on, every setting the file changed shows ticked, because on that path the file really does drive the whole slice. Taking the designer's work in bulk is still one click, from a line at the top of the settings panel that says how many settings the file changed — and it still leaves the machine-tuned ones and the two that *are* the picked preset for a per-key decision, which is the classification #2622 made and this does not widen. Two things underneath had to change for the checkbox to mean what it says. The panel greys out options the slicer's own rules switch off, and it was evaluating those rules against what the user had typed alone, falling back to the compiled-in schema defaults for the rest — so a preset with supports on read as `enable_support: false` and greyed out the whole Support page while the slice ran supports. A greyed row greyed its tick too, which is how the reporter's screenshot shows a support type marked "from file", applied to the slice, and impossible to clear. The rules now see what the slice will actually run with: the preset's values, the file's values for the keys that are on, and anything typed on top. And the tick is no longer gated on the slicer's rules at all, because it answers a different question — not whether an option is in play, but where its value comes from. Separately, the support carry-over from #1881 ran underneath the ticks entirely, lifting four support keys out of any 3MF that had supports on, with nothing on screen to decline. It now stands down for the keys that were offered and turned down, which the request can say for the first time: an empty `design_overrides` list means the caller was shown the file's settings and took none, where no list at all is a caller that predates the choice and keeps #1881 whole — as do sources that record no deviations to tick, an OrcaSlicer export among them. Covered by backend and frontend tests, and measured against the reporter's own sixteen keys. Note the practical change: a MakerWorld file with supports enabled no longer switches supports on for you — tick **Enable support** in the panel, or the checkbox above it, if that is what you want. - **A colour mismatch was reported between two filaments the app itself called "Blue" (#2941)** — The print dialog compared a slicer profile's near-pure `#0028FF` against the Bambu navy `#0A2989` loaded in A4 and correctly said they differ: 118 apart in the blue channel alone, a CIEDE2000 distance of 15 where 1 is a just-noticeable difference. Nothing in the dialog said so. A hex that misses the colour catalogue is named by a coarse family bucket, so both sides resolved to the name "Blue", and the warning sat between two identical labels with no way to tell what it was objecting to — the reporter read it, reasonably, as the matcher being broken. Where both sides of a mismatch carry the same name they are now qualified by their hex, and the tooltip names them together: "Same type, different color: needs Blue (#0028FF), slot has Blue (#0A2989)". Names that already differ are left alone, since the hex is noise once the words separate them. The comparison itself is untouched — it was right, and its tolerance is not the kind of thing to widen on a single report: admitting a difference that large would start matching navy to cyan, and the eligibility rule is shared with the queue scheduler, which would then dispatch on it. The panel's own status line, the required-filament tooltip, the auto-matched/manual marker, the slot placeholder and the type-not-found message were all hardcoded English; they are translated now, in all thirteen locales, and two of them turned out to have had translations sitting unused in every locale file the whole time. - **A print that failed on an `hms[]` fault recorded an unlookupable error code** — The queue's failure reason is built by formatting the fault's module and error into `MMMM_EEEE`, and that one derivation never masked the error to 16 bits. A fault arriving from the printer's `hms[]` array carries its alert level in the code's high half, so the label came out as e.g. `0500_3000A` — five digits in a group that has four. It is not a code the user can look up on Bambu's HMS index, and because it matches no catalogue key the sentence explaining the failure was dropped along with it, leaving the bracketed number alone. The nozzle-size mismatch behind #1111 is exactly such a fault: it reads as `[0500_4038] The nozzle diameter in sliced file is not consistent...` when the printer reports it one way and read as a bare `[0500_24038]` when it reported it the other. There was already a helper that gets this right and is used by the archive's own failure-reason lookup; the queue's now calls it instead of keeping a fourth copy of the derivation. - **AMS slots were offered as places to store a spool** — The Storage Location dropdown in the spool editor listed entries like "H2D-1 - AMS A1" alongside real locations, and they could not be removed. They are not locations at all: Bambuddy used to record which slot a spool was loaded into by writing that string into Spoolman's `location` field, and although that writer went away when Storage Location became something the user picks, the strings stayed on people's Spoolman spools — where the location sync, which imports every distinct one it finds, has been reading them back ever since. A printer slot is where a spool is loaded, not where it is put away, and Bambuddy already tracks the first through slot assignments. Deleting one by hand did not work either, which is what made this a dead end rather than an annoyance: the delete route refuses a location that has spools, and in Spoolman mode it counts them by matching that same string, so every marker still sitting on a loaded spool answered 409 — and the two that were empty came back on the next sync a minute later. The import now skips them, and the ones already in the catalogue are removed on upgrade. The filter is deliberately narrow, matching only the shape Bambuddy itself wrote — an optional printer-name prefix followed by `AMS A1`, `AMS-HT A1` or `External Spool` — so "AMS Drybox" and "Spare AMS trays" are left alone; anything it swallowed would be a place the user could no longer file a spool under. A row is only removed when no spool in Bambuddy's own database points at it, by id or by legacy free-text name, so an internal-mode user who has deliberately filed spools under such a name keeps it. Spools in Spoolman are not touched: their location strings are the user's data on the user's server, and one that still reads "H2D-1 - AMS A1" in the inventory list is telling the truth about what Spoolman holds — it simply stops being offered as a destination. - **A wood, silk or gradient roll the AMS added for you was drawn as a flat disc** — A spool's swatch is composed from `effect_type` and `extra_colors`, and the RFID auto-add set neither. It reads the colour catalogue to name the colour and took the name alone, even though the row it had in hand also carries those two columns — the spool form's own colour picker hands both to a spool a user adds by hand, so the same roll rendered one way when you typed it in and another when the printer identified it for you. Both columns now travel with the name. That alone would have changed nothing on a stock install, because the shipped catalogue carries an effect on none of its 600-odd rows, so the subtype is read where the catalogue has none: it is already derived from what the printer reports, and the two vocabularies line up — Wood, Silk, Sparkle, Marble, Glow, Galaxy, Metal, Rainbow, Translucent, Matte, and the Gradient, Dual Color and Tri Color that the M*/T* colour codes upgrade a subtype to. "Silk+" is read as Silk, since the plus is on the product name rather than the finish. A subtype that names no effect — Basic, Tough, CF — leaves the column empty rather than inventing an overlay, and a value already set is never overwritten, so the column stays what it is documented to be: a rendering hint the user can override without touching Bambu's categorical label. - **Every Bambu RFID spool was added with the wrong empty-spool weight (#2909, reported and fixed by @ojimpo in #2923)** — A Bambu roll arrives on the 250 g Low Temp spool, but the lookup that gave an auto-added spool its `core_weight` asked for the first catalogue row whose name starts "Bambu Lab" and took whatever came back. There are three of them, and there was neither a matching step nor an `ORDER BY` to choose between them: SQLite hands back insertion order in practice, which is the 216 g High Temp row, and Postgres promises nothing at all once the table has seen an update. So the same roll was recorded 34 g light on one install and correctly on another, and nothing about the spool said which had happened. The row is now matched by name, case-insensitively so a re-typed entry still counts, with the lowest id winning if a user has two rows sharing a name — without that tiebreak the read does not merely pick badly, it raises and the AMS read fails outright. A missing or renamed row falls back to the documented 250 g rather than to another catalogue row, because falling back to a row is how the arbitrary pick started; an install that has re-measured its own spools gets its own number. The path also records *which* row supplied the weight, which it never did — and that column is not the cosmetic detail it looks like. The spool form does not leave it blank: it auto-selects whenever exactly one catalogue row matches the weight, and shows the first match's name otherwise, so an arbitrary tare was displayed as a named row and written back as that row's id the next time any field was saved. A wrong number was being laundered into what reads like a deliberate choice, which is why the column being empty was the *less* damaged state. The row name and the 250 g fallback are shared with the upgrade repair below rather than written twice, so a repair can never go hunting for a row the creating path does not write. Covered by backend tests, including the duplicate-name case that would otherwise fail hard. - **The spool tare an RFID roll was added with is corrected on upgrade (#2909, diagnosed by @ojimpo)** — The lookup that gave an auto-added spool its `core_weight` asked for the first catalogue row whose name starts "Bambu Lab" and took whatever came back. There are three, and which is first is the database's business: SQLite returns insertion order in practice, Postgres promises nothing once a table has seen an update, so the same roll was recorded with the 216 g High Temp tare on one install and correctly with the 250 g Low Temp one on another. The forward fix picks the row by name; this repairs the rows already written, which the forward fix cannot reach. The tare is not cosmetic: a spool weighed on SpoolBuddy has its remaining filament worked out as the scale reading minus the tare, so a 34 g low tare credits the roll with 34 g that is not there and writes a used weight 34 g short. That error is a constant — every later print adds to the used weight on top of it — so adding the difference back is exact however much has been printed since, and it is applied only to spools that have actually been on the scale; one that never was has a used weight derived from the AMS remaining percentage, which the tare never entered into. The rows to repair are identified by the signature of the broken lookup — added by RFID, carrying the weight of one of the *other* Bambu catalogue rows — with the weights read out of the catalogue rather than hardcoded, so an install whose rows have been re-measured is repaired to its own numbers. One case cannot be told apart and is stated rather than hidden: someone who moved an RFID roll onto a genuine High Temp spool and set 216 g by hand looks identical to a row the lookup got wrong and is normalised with them. Keying on whether a catalogue row had been recorded would not have rescued them either — the weight picker auto-selects the only row matching the weight and writes its id on the next save, so that column says only whether the form was ever opened. Runs exactly once, so a tare set afterwards is kept. - **A wood-filled spool was named as plain PLA on the slot it was assigned to** — A spool's subtype is half of what it is called: "PLA" and "PLA Wood" are different filaments, and the AMS slot's hover card built the assigned-spool line out of brand, material and colour name with the subtype left out. A roll of Bambu PLA Wood Classic Birch in an H2C's A4 was therefore announced as "Bambu Lab PLA - Classic Birch". Everything else named it correctly at the same moment — the RFID read, the inventory row, the slot's own profile line, which is built from the spool's slicer preset rather than reassembled, and Bambu Studio — so the one wrong line read like a bad tag read rather than a display fault. It was not only the render: the card's `assignedSpool` prop had no subtype field at all, and the six places the printer card fills it in (regular AMS, AMS-HT and external spool, each in both Spoolman and internal-inventory mode) never passed one, so the value could not reach the component. The field is required now rather than optional, which is what stops the next call site from quietly omitting it — that omission is the whole of this bug. Three more surfaces were rebuilding the name the same way and are fixed with it: the SpoolBuddy AMS slot panel in both inventory modes, and the write-tag confirmation. Every other place a spool is named — the assign dialogs, the inventory cards, the forecast rows, the label picker — already included the subtype, so these four were the outliers. This is the display-side half of #2902, which stopped the backend reducing a filled or foamed filament onto its base material; the card was doing the same thing to the same spools, one layer further out. - **The bundled chamber-preheat table was unreadable to the code that reads it** — every lookup of the per-filament chamber map happens after the keys are upper-cased, but an install that had never opened the setting got the bundled table back exactly as declared, with its lowercase `default` row. The scheduler then looked for `DEFAULT`, found nothing, and used a hardcoded 0 for any filament without a row of its own. It reported the right number only because that bundled default is 0 — raising it would have silently changed nothing for everyone who had not customised the map. Both paths out of the parser now honour the one contract it documents. - **The archives API never reported which plate was printed (#2796, contributed by @sgiffhorn)** — `archive_to_response()` builds the archives response field by field and had no line for `plate_id`, so `GET /archives/`, the detail endpoint, search, PATCH and the project archive list all answered `plate_id: null` — for archives whose column was populated as well. `ArchiveResponse.plate_id` defaults to `None`, so Pydantic filled the null in without complaint and nothing ever raised. The column has been written since #2603 and is backfilled from the queue on startup, so the plate was recorded all along and simply could not be read back out; on the reporting instance 181 of 257 rows carry one. Archive cards now name the plate again — but only when it is not the first one. The queue records a plate for single-plate files too, because the print dialog auto-selects the only plate there is, so labelling every archive that has a `plate_id` would have put "Plate 1" on most cards in the grid and taken room from the truncated print name. A multi-plate archive printed from its first plate still identifies itself through the plate carousel, which has always gated on whether the source 3MF holds more than one plate. - **The first AMS sync after enabling Spoolman from Settings failed on every slot (#2903, diagnosed by @ojimpo)** — Spoolman rejects a spool whose `extra` dict carries a key it has not been told about, answering HTTP 400 `Unknown extra field tag.`. Bambuddy stores the tray UUID in `extra.tag`, so that key has to be registered before the first spool is created — and registration only ever ran from three hand-maintained lists that fire when the integration is *set up*: the Connect button, application startup, and two inline blocks in the inventory routes. Enabling Spoolman from the Settings page reaches none of them, so "Sync AMS Data" reported `Synced 0 spools with 3 errors` with vendor and filament creation succeeding and only spool creation rejected. Restarting Bambuddy cleared it, which made the failure look like a connectivity problem. The Connect button would also have fixed it, but it is not on screen by then: saving the settings initialises the Spoolman client as a side effect of syncing locations, the status endpoint reads any live client as "connected", and the UI shows the Connect button only while disconnected — so the one registration path reachable from the interface hides itself exactly when it is needed. Registration now travels with the write instead of the feature: every spool write that carries an `extra` dict registers the keys it is about to send, once per client, before it sends them. That covers all five places Bambuddy writes a tag — AMS sync, linking and unlinking a tag from a spool, and both inventory edit paths — and it closes the class rather than the instance, since a write that carries a key is now a write that registers it. `bambu_color_name` is the cautionary case: it never made it into the Connect or startup lists at all, and worked only because two call sites remembered to register it by hand. Registration stays best-effort — if it fails, the write is still attempted and reports exactly what it reported before, and the failure is not cached, so a Spoolman that was merely restarting is retried on the next write. - **The Spoolman connection status described Bambuddy's memory rather than Spoolman (#2903)** — "Connected" meant "some earlier request in this process left a client object behind", and around twenty code paths build one lazily, so the answer turned on which page had been opened rather than on anything about Spoolman. A switched-off integration could still report Connected from a leftover client; changing the URL kept reporting on the previous host; and saving the Settings page built a client as a side effect of syncing locations, which is how enabling Spoolman came to report Connected before anything had been set up. The status now asks the Spoolman that is configured right now, so the answer is the same whatever you opened first. The Disconnect button is gone with it: Spoolman is a stateless HTTP API with no session to close, so the button only dropped the client object that the next request rebuilt moments later, after which the status flipped back on its own — it looked like it had worked, then quietly undid itself. Turning the integration off is the enable toggle's job, and Connect remains as what it actually is, a way to re-check a Spoolman that is not answering. - **An AMS slot assigned a PLA+ spool became unusable for PLA (#2902, reported by @doncaruana)** — Assigning a spool wrote its material straight into the slot's `tray_type`, and a slot that says "PLA+" satisfies nothing that asks for PLA: not OrcaSlicer, not Bambu Studio, and not Bambuddy's own dispatch matcher, which compares the printer's reported type to the one the 3MF declares. The same string then missed the filament-id lookup, so the slot went out with no `tray_info_idx` at all — the half-configured state a printer reverts from — and took the generic 200/240°C nozzle range instead of PLA's. PLA+ is not a special case: Bambuddy's own colour catalogue supplies the material dropdown, and about forty of its values are vendor product lines rather than filament types — HTPLA, PolyTerra PLA, PLA Matte, ASA Extrafill, Flexfill TPU 98A. All four routes that configure a slot now reduce the material to a type the printer knows before sending it, and the product name moves to `tray_sub_brands`, which is where Bambu Lab itself puts it — their catalogue has a preset named "eSUN PLA+" whose type is PLA. A material that cannot be placed is sent exactly as before rather than guessed at, so this can only repair a slot, never break a working one; and a material that already had a preset of its own keeps it, so "PETG HF" is not quietly downgraded to plain PETG. One thing that starts working as a result: a slot holding a calibrated preset is now reused when a same-material spool is assigned to it, which for these spools could never happen before. A filled or foamed variant is a type in its own right and is left alone: PLA-AERO, PLA-GF, ASA-GF and PPS-GF were being reduced onto their base material, so a plain PLA plate could have been dispatched onto foaming filament — every type the Profiles editor offers now reaches the slot intact, whether it is written "PLA-AERO" or "PLA Aero". And when a spool points at a slicer preset, the slot takes that preset's own filament type rather than one read out of the material column, since a preset is chosen from a list the slicer defines; the material is still what a spool without a preset is read from. Two lookups keyed by material had to learn the same distinction: a variant with no nozzle range of its own now takes its base material's rather than the 200/240 catch-all, so an ASA-GF spool is no longer sent out at ASA's minimum minus thirty degrees, and the preheat chamber target does the same — ASA-CF and ABS-GF reach a warm chamber for the first time, while PETG-CF and PA-CF keep the hotter rows they are listed with. - **A failed upload told you to check the SD card, whatever had actually gone wrong (#2899, reported by @grolmus)** — Every dispatch upload that failed carried the same sentence: "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)." The reporter got it after a TLS handshake failure and restarted the printer on the strength of it. That could not have helped — the handshake never reached the printer's filesystem, and the cool-off that made the next dispatch fail identically lives in Bambuddy's own memory, where power-cycling a printer does not reach. #2780 had already taken operator advice out of this failure's log line, for exactly the reason that the advice was known not to work; it survived in the string people actually read. The information to say something true was never missing. The FTP client separates five connect failures and three upload reply codes, each with its own log line — 553 even gets a spelled-out list of storage causes — and then handed the caller a bare true-or-false, so the dispatch had nothing to go on and guessed storage for all of them. The reason now travels with the result, and the message is chosen from it: a 553 or 552 keeps the card advice, which is the case it was written for, and quotes the printer's reply code so a queue entry and a support bundle can be lined up. A handshake failure says the file service answered without TLS and that the card is not involved. A refused connection points at the access code, a timeout at the network, and anything the client could not classify says so and points at the log rather than picking a plausible cause — a wrong instruction costs more than a vague one, because it sends someone to work on hardware that is fine. No message prescribes a power cycle, which is the restraint #2780 settled on. The failure notification carries the same sentence the queue shows, rather than its own fixed "Failed to upload file to printer", so a push and the screen can no longer disagree. - **One handshake failure took out three queued jobs and every retry they had (#2898, reported by @grolmus)** — A print dispatch that met a TLS handshake failure spent its whole retry budget without opening a single socket. The cool-off that a failed handshake arms (#2780) lives inside `connect()`, so it applied to everything — including the dispatch, whose four attempts two seconds apart were all answered from the gate rather than the network. The reporter's farm logs the shape exactly: the delete that clears the way for the upload took the SSL error at 11:10:04.956, the upload's first attempt started 8ms later, and attempts two through four took one to two milliseconds each. Because the cool-off runs for five minutes, the next two jobs queued for that printer failed the same way inside the same window, and on that farm the failure is transient — a manual connect a second later completes cleanly — so the retry the gate suppressed is the one that would have worked. The gate was serving two callers that want opposite things from it. The background sweeps that fetch a 3MF, a cover or a timelapse after a print walk about a hundred and ten candidate paths against one wedged printer with nobody waiting, and backing off for minutes is right for them; they keep today's behaviour untouched. A dispatch is one delete plus at most four upload attempts with someone watching a progress bar, so it now ignores the cool-off, as does a firmware upload, for the same reason. Callers that do respect the cool-off no longer sleep out a retry loop against it either: the loop stops at the attempt that armed the gate and says so, instead of spending three more attempts and six seconds on connections that cannot happen. What made this a log dive rather than a glance is fixed with it: the cool-off skip was the one connect failure that reported without naming its cause, and did so at debug level, so four identical reason-free warnings were all the operator saw. It now says at warning level that nothing was sent and how long the printer has left — once per cool-off rather than once per attempt, so that raising it does not recreate the log flood #2780 set out to stop. - **Archived projects crowded out the live ones in every project picker (#2888, reported by @e77)** — The reporter files each job under its own project and archives it when the job is done, so five active projects sat behind thirty-odd finished ones in the Project dropdown of the Edit Archive dialog — an unscrolled list of everything ever created, with no way to tell which entries were still live. That dropdown, the one on the pending-uploads panel, the bulk "Add to Project" dialog and the File Manager's folder link now leave archived projects out. Completed projects stay: a project marked completed says the work is done, not that it should be hidden, and filing a reprint under one is ordinary. The Archives right-click submenu had gone the other way and offered active projects only, so a completed project was reachable from the Edit dialog and not from the menu beside it; all five surfaces now apply the same rule. Whatever an archive is already filed under stays on its own list whatever its status — a `` in small grey italics and ignore styling on it. Imported and Orca Cloud presets carry no Bambu filament ID, and the printer indexes its calibration table by one, so those are filed under the closest generic for their material — the same rule the AMS slot configuration already uses, so a profile created here matches the slot configured there. A filament whose material Bambuddy can't place is refused with an explanation rather than written under a wrong ID. Also drops a second, redundant K-profile fetch the old dropdown needed: it ran concurrently with the main one whenever a non-0.4mm nozzle was selected, which is exactly the case that made K-profile requests time out. Translated in all locales; wiki updated. Covered by frontend tests. - **K-profiles no longer all report 0.4mm / High Flow (#1748, reporters @Liquidmasl and @jmoore-skild)** — On any printer running a nozzle other than 0.4mm, every K-profile showed up as `0.4` with a flow type nobody had set, and the same profile disagreed with itself: the list said **S**, the edit dialog said **High Flow**. The printer reports the nozzle diameter once, on the response envelope; the individual profile entries carry no diameter and no nozzle id at all. Bambuddy read the diameter *per entry* and fell back to a hardcoded `0.4` when it wasn't there — which was always. The envelope value is now used, so profiles report the nozzle they were actually calibrated for. This was not only cosmetic. Editing a profile is delete-and-re-add on single-nozzle printers, and the dialog rebuilt the nozzle fields from its own (greyed-out) dropdowns, so saving an untouched 0.6mm profile rewrote it on the printer as 0.4mm High Flow. Deleting one aimed the command at the wrong nozzle for the same reason. Both now pass through exactly what the printer reported. Assigning a spool's stored calibration to an AMS slot was affected too: that lookup matches on nozzle diameter, so on a 0.6 or 0.8 nozzle it never found the printer-side entry and the cali_idx silently failed to stick — the "can't auto-map a K-profile" half of the report. Where the printer sends no nozzle id, Bambuddy now says so instead of picking one: the list shows the diameter alone, the dialog shows **Not reported by printer**, and the High Flow / Standard filter is hidden rather than offered as a control that can only ever empty the list. Also fixes the flow type filter selecting the opposite label when naming a new profile. Translated in all locales. Covered by backend tests. - **K-profile requests no longer time out when two run at once (#1748)** — Fetching profiles for one nozzle size while another fetch was open made the first one time out, with `Failed to get K-profiles after 3 attempts` in the log, even though the printer had answered both correctly. Responses were matched to requests by nozzle diameter held in a single shared slot, so the second request overwrote the first's expectation and the first's valid answer was discarded as a mismatch. Requests are now correlated by the sequence id Bambuddy already sends and tracked one entry per request, with the old nozzle match kept as a fallback for firmware that doesn't echo the id back. An unsolicited broadcast arriving mid-fetch also no longer replaces the profile list the fetch is waiting on. Covered by backend tests. - **Git backup now actually writes cloud profiles (#2717, reporter @jmoore-skild)** — Enabling **Cloud Profiles** for a Git backup produced nothing. The collector looked for a `setting` list in the Bambu Cloud response, which is keyed by preset type instead, so the loop never ran once — and it asked for the credential store used when authentication is *disabled*, so on any install with authentication on it found no account to collect from in the first place. Neither failure was visible: `backup_metadata.json` still recorded `cloud_profiles: true`, and the log line read `Collected cloud profiles: 0 filament, 0 printer, 0 process`, which looks like a successful backup of an empty account. Cloud profiles are now collected from **every connected account across both Bambu Cloud and Orca Cloud**, one directory per cloud per account, keyed by user ID so no email address is written into a backup repository. Bambu presets are stored with the payload needed to recreate them rather than just their names, and Bambu's bundled public catalogue is skipped — it is identical for everyone, re-downloadable, and would rewrite the repository on every run. The metadata now records what was actually collected, per cloud and per account, and a run that collects nothing while the category is enabled says so as a warning instead of an INFO line that reads like success. The Cloud Profiles checkbox no longer keys off your own Bambu sign-in — it enables when *any* account is connected and shows how many are in scope, which matters on a multi-user install where the presets being backed up are other people's. A backup also no longer disconnects an Orca Cloud account whose session it can't refresh: Orca reports every rejection with one composite reason, so a genuine revocation is indistinguishable from a lost token-rotation race, and an unattended job should not be the thing that guesses. The account is skipped with a warning, and the dead credentials are cleared the next time you open Orca Cloud Profiles — where you can pair again on the spot. Translated in all locales; wiki updated. Covered by backend tests. - **A heavy model failed to slice after five minutes with "Slicer sidecar unreachable" (#2730, reporter @kpp39)** — A MakerWorld model that Bambu Studio also takes a long time over never finished slicing in Bambuddy: five minutes in, it failed claiming the slicer sidecar could not be reached. **Root cause.** The slice request carried a fixed five-minute limit covering the whole operation, and it was applied to the wrong thing. Slicing is a single long request, so the limit was a ceiling on how long a model was allowed to take — not a check on whether anything had gone wrong. When it expired, the resulting error was indistinguishable from a genuine connection failure, so a slice that was progressing normally was reported as an unreachable sidecar. The reporter went and updated their sidecar container, which was never the problem: it was reachable throughout and still slicing when Bambuddy hung up on it. **Fix.** Bambuddy already polls the sidecar once a second for progress — that is what drives the live progress toast — so it can tell a slow slice from a stuck one, and now does. The limit applies to *silence*: a model that keeps reporting progress runs to completion however long it takes, and a slice is only abandoned when the slicer has said nothing for the configured period. The new **Slicer stall timeout** under Settings → Workflow → Slicer sets that period, defaulting to fifteen minutes, and the failure message now says the slice ran out of time and where to change it rather than blaming the connection. Sidecars too old to report progress have no liveness signal to offer, so for those the setting still bounds total slicing time — the previous behaviour, but configurable and no longer five minutes flat. A sidecar that genuinely cannot be reached still fails immediately and still says so. Translated in all locales; wiki documents the setting. Covered by tests for a slow-but-progressing slice completing, a stalled one failing, a frozen progress report not counting as progress, the two failure messages, and the setting falling back safely when unset or unparseable. - **Deleted prints stayed in their project as cards with broken previews, and could not be removed (#2731, reporter @sroesner)** — Deleting a print that belonged to a project left it on the project page with a missing thumbnail, and there was no way to unassign it. **Root cause.** Deleting a print is a soft delete by default: the files go from disk, the row stays so Quick Stats keeps counting its filament, time and cost. Every other part of Bambuddy skips those rows — the projects module skipped none of them, so a deleted print kept its project link and kept being listed, pointing at a thumbnail that no longer existed. The same broken previews appeared on the project cards in the overview, not just the detail page, and in the project timeline, where clicking the entry led to an archive that no longer opens. Unassigning was impossible because the only way to change a print's project is from the Archives page, which correctly hides deleted prints — so the entry could be seen but never reached. **Fix.** A deleted print now leaves its project everywhere: the archive list, the card previews, the timeline, and the counts. Excluding it from the *counts* is a deliberate difference from how Quick Stats treats the same print — a project is a piece of work with a definite membership rather than a lifetime total, so a project that lists eleven prints should not claim twelve. Existing broken entries disappear on upgrade with nothing to clean up; the API can still clear a stale link if anything needs repairing. Two more places were counting deleted prints for the same reason and are fixed with it: the archive CSV/Excel export handed back rows the interface says are gone, and per-project failure analysis measured a failure rate against prints that had been deleted from the project. The project page also no longer needs a manual reload to catch up: deleting a print refreshed the archive list but nothing project-related, and assigning a print to a project refreshed the project cards but not the project page itself, so for the following minute either view could still be showing what was there before. Covered by tests for the listing, the card previews, the timeline, the counts, both services, the cache refresh, and a guard that a project's live prints are untouched by any of it. - **A printer refusing Bambuddy's commands looked healthy, and its queue failed with the wrong advice (#2732, reporter @hennischd)** — Uploads succeeded, the printer echoed the job back, then sat idle for 270 seconds and the job was re-uploaded twice more before failing with a message about SD cards. Temperature changes returned success and did nothing. The connection diagnostic passed every check, and the support bundle said Developer Mode was on. **Root cause.** The printer was rejecting every control command and saying so: HMS `0500-0500-0001-0007`, "MQTT command verification failed" — the firmware's authorization check, which Bambu Lab documents Developer Mode as the way to disable. Nothing in Bambuddy connected that to anything. The error itself was received and then discarded by the frontend, because this code's meaning lives in bits that Bambuddy's short-code form throws away: it collapses to `0500_0007`, matches no catalog entry, and uncatalogued errors without firmware actions are filtered out of the badge count and the error list. Meanwhile the Developer Mode probe reads any response that isn't an explicit refusal as confirmation, and this firmware answers the probe with an empty result while refusing everything else — so Bambuddy inferred a healthy printer from a non-answer, and reported that inference as a passing diagnostic. **Fix.** HMS codes are now looked up by their full identifier before the short form, so this error survives to the screen, shows the four-group code the printer's own display shows, and carries the fix rather than Bambu's "update Studio or Handy" (which does not apply to a print sent from Bambuddy). The error is treated as authoritative about the printer's state: it sets Developer Mode to off regardless of what the probe concluded, which makes the diagnostic and the support bundle report the real situation, and it clears itself when the printer stops reporting it, so enabling Developer Mode and restarting is picked up without restarting Bambuddy. The probe no longer reads an inconclusive answer as confirmation — it reports what it knows, which for this firmware is nothing. A queue item whose command is rejected now fails on the first attempt naming the code and the fix, instead of spending three uploads and fifteen minutes of a farm's upload capacity to arrive at the wrong conclusion; a print that is visibly running is never touched, whatever HMS is lingering. Separately, the log hint suggesting a wrong or mis-cased serial number no longer fires in the moment after a reconnect, when the report counter it reads has just been reset and proves nothing — it cost this reporter a detour through their serial number on a printer whose serial was correct. Translated in all locales. Covered by tests for the code surviving the filter, the display form, the developer-mode override and its self-clearing, the inconclusive probe, first-attempt failure, the running-print guard and the suppressed hint. - **A printer that dropped off MQTT could stay offline indefinitely (#2732, reporter @hennischd)** — In the same bundle, the printer lost its MQTT session to a keep-alive timeout at 02:19 and did not come back until 11:24 — nine hours offline, with the web UI open the whole time. Bambuddy's stale-session detector only covers the other failure: a session that is still connected but has gone quiet. Once the connection is *down*, it returns immediately and the MQTT library's own retry is the only thing still watching; when that stops making progress, nothing notices. Bambuddy now runs a backstop sweep every minute. A printer that had a working session, has been silent for five minutes, and still answers on its MQTT port gets its client rebuilt from scratch with a fresh session — which also drops any command left unacknowledged on the dead one, so it cannot replay into the new session. Printers that are simply switched off are left alone: the port check tells the two apart, so there is no client churn and no nightly log spam for a farm that powers down. The rebuild is rate-limited per printer and never touches a connected one, and the log line names how long the printer was gone and what the last connection error was, so a session that dies repeatedly leaves a trail. Covered by tests for the recovery itself, the grace period, the switched-off case, the retry interval, and a farm sweep continuing past a printer that throws. - **A slot on Generic PLA offered only one K profile, however many the printer held (#2710, reporter @tommyboy180)** — The printer's Flow Dynamics table held nine calibrations, all of them under Generic PLA; Configure AMS Slot offered exactly one, the one already bound to the slot, and after a slot reset it offered none at all. The only way to assign the others was Bambu Studio. **Root cause.** Two independent faults, both triggered by picking a built-in generic preset. First, K profiles are matched to the selected preset by filament ID, but matches on Bambu's generic IDs (`GFL99` for Generic PLA, `GFG99` for Generic PETG, and so on) were deliberately thrown away as too broad — and since the comparison already requires both sides to carry the *same* ID, that exclusion could only ever fire when the chosen preset was itself the generic one, which is precisely the case where the match is right. Second, the matcher read the leading "Generic" in "Generic PLA" as a manufacturer, and so demanded the word "Generic" appear in the K profile's name; no real profile has it, which killed the name-based fallback as well. The one profile that did show up came from an unrelated safety net that always surfaces the slot's active profile, and a reset slot has none — hence the empty list. **Fix.** A K profile whose filament ID equals the selected preset's now matches, generic or not: the printer keeps one calibration table per filament ID, so a slot on Generic PLA offers everything calibrated under Generic PLA, whatever the user named those entries — "Dark Brown", "Marble", "Glow" and the rest now all appear. "Generic" is no longer treated as a brand, so profiles still match by material when a printer reports no filament ID at all. Since no name-and-ID matcher can be perfect against profile names the user invents, the picker now also lists every remaining profile on the printer under **Other K profiles on this printer**, so a profile that exists can always be selected; picking one works exactly as before, because Bambuddy already realigns the slot's filament context to whichever profile is chosen. The same generic-ID rule now applies to the spool form's K profile suggestions, guarded so it can never cross materials — a PETG spool is never offered PLA calibrations — and so that a spool naming its own brand keeps its brand-specific suggestions. Translated in all locales. Covered by tests for the reporter's nine-profile printer, the reset slot, printers that send no filament ID, the other-profiles group and applying a profile from it, and by guards that a brand preset still does not sweep in generic profiles. - **The print-complete photo showed the toolhead still printing, three minutes before the print ended (#2547, reporter @anthonyma94)** — The Discord photo caught the model mid-print with the head over it, instead of the finished print. **Root cause.** Bambuddy fired the photo the moment `layer_num` reached `total_layer_num`. That edge is the moment the printer *starts* its final layer, not the moment it finishes it: on the reporter's H2C it arrived at 92% with two minutes of print still to run, and the last layer took three minutes and seventeen seconds including a filament change. Worse, that trigger latched, which locked out both of the triggers that fire at a genuine end of print — so on printers that never report an end-of-print filament unload (H2C and A1 Mini confirmed) there was no way back to a correct photo. **Fix.** The last-layer trigger is gone. The photo is taken when the print reports itself finished, which is a signal every model sends and which lands after the toolhead has parked. Since the printer's own end G-code drops the plate about 100 mm just before that, Bambuddy now raises it back to just above the last printed layer, takes the photo, then lowers it again — restoring the framing asked for in #1145, #1397 and #1565. The plate move is an absolute Z to a height the toolhead occupied seconds earlier, so it stays inside the travel limits and keeps the nozzle above the part, and it is skipped outright unless Bambuddy can confirm the height belongs to this print: the sliced file is matched by print name, and its layer count is cross-checked against the layer count the printer reported. It is also skipped when another job is queued, when the printer has moved on, and when the new **Restore plate for finish photo** setting is off. Prints whose End G-code Bambuddy injected — SwapMod plate swaps and similar — are detected automatically and keep using a frame from during the print, since their model has left the bed by then (#1867); that frame now also refreshes through the final layer instead of freezing when it began. Prints that record a timelapse normally source the photo from the video's last frame and need no move at all, but when the video has not transferred in time — the usual outcome on P1-series — the live photo that ships in the notification now gets the same plate restore, so it is no longer a shot of an empty-looking lowered bed. The `stg_cur=22` trigger is left in place for any firmware that does emit it, but the bundle survey above still applies — in practice every model reaches the finish-state path. Translated in all locales; wiki updated. Covered by backend tests for the removed trigger, the two surviving ones, the plate move and every condition that suppresses it, and by frontend tests for the new setting. - **A model sliced for PETG printed as PLA, and the print dialog then refused to match PETG (#2712, reporter @kpp39)** — Slicing a MakerWorld model with a PETG profile produced G-code the printer wanted PLA for, and the Filament match step offered no way to correct it. **Root cause.** The list of filaments the slice dialog shows is positional: the first row is the printer's first slot, the second row its second, and so on down to the slicer itself. For a model that already carries slicing information, Bambuddy listed only the slots that print — which is the right answer when you are starting a print and Bambuddy has to match spools in the AMS, and the wrong one here. The reported model declares four filaments and paints with the fourth alone, so the dialog showed a single row; the PETG chosen in it became the *first* slot, and the fourth — the one the model actually prints with — kept the profile baked into the downloaded file. The result was a genuine PLA print, so the Filament match step was right to insist on PLA. **Fix.** When picking profiles for a slice, the dialog now lists every slot the project declares, with the ones this plate doesn't print with shown greyed out as before, so each row lines up with the slot it stands for and a choice made in the fourth row reaches the fourth slot. Starting a print is untouched and still asks only for the spools the job needs. Covered by tests for a source whose only printed slot is the fourth, for the print path keeping the shorter list, and for the chosen profile arriving in the right position. - **A finished slice produced a stream of a dozen "Sliced ..." notifications** — One slice reported itself complete over and over, a notification every second and a half for as long as twenty seconds. **Root cause.** While a slice runs, Bambuddy asks the server how it is getting on every 1.5 seconds — but it never waited for an answer before asking again. Slicing a large project keeps the server busy for seconds at a time, so those questions piled up unanswered, each one still believing the job was running. When the server caught up it answered all of them at once, and every single answer was treated as the moment the slice finished: one notification each, one list refresh each. The bigger the project, the longer the pile and the more notifications. **Fix.** Bambuddy now waits for an answer before asking the next question, so nothing can pile up and a busy server isn't asked to do more work while it is already behind. A job's completion is also recorded once and only once, and a check that was already in flight when the tracker restarts now stops instead of finishing its work — either of which is enough on its own to keep a duplicate off the screen. Covered by tests for a server stalled across many intervals, for two slices finishing where one restarts the tracker, and for the same job being tracked twice in a row still reporting both times. - **A database hiccup during dispatch could leave a queue item stuck and the next print of that file filed under the wrong archive** — When PostgreSQL briefly refused a connection in the middle of dispatching a queue item, the dispatch failed part-way through and left two things behind. **Root cause.** Bambuddy tells itself to expect a print just *before* it sends the print command, because the printer can report the job before the send even returns — but nothing undid that expectation when the command was never sent. The entry does expire after two hours, which is far longer than it takes to react to a failed job by pressing print again: that reprint was folded into the old archive and inherited its filament mapping and plate instead of getting a fresh one. Separately, releasing the row's dispatch lock is best-effort and needed the same database that had just refused, so it gave up after one attempt and the item stayed invisible to the scheduler until a restart. Nothing was ever sent to the printer — the failure happens before the print command — so this cost a stuck item, not a wrong print. **Fix.** An expectation is now withdrawn whenever the print command doesn't go out, which also covers two cases that were silently leaking before: a job cancelled during dispatch, and a print command the printer rejects. The dispatch lock is retried rather than abandoned after one try, and any lock left behind is released on the next quiet moment instead of surviving until a restart. Covered by tests for the withdrawal being an exact inverse of the registration, for a confirmed print keeping its expectation, for two dispatches not disturbing each other, and for the lock recovering from both a brief and a sustained database outage. - **An unusable layer number from a printer could drop its connection (#2702 follow-up)** — Reading the current layer from a status message assumed it would always be a number. Anything else raised an error out of the routine that reads those messages, and nothing above that point catches it, so the connection's listener stopped and the printer looked silent until the staleness check rebuilt it — losing not just the layer number but the print-start and print-finished detection carried in the same message. An unusable value is now ignored, holding the last known layer rather than substituting zero, which the firmware uses to signal a cancellation. This is the same containment applied to the layer *total* in this release, three lines away in the same routine. Covered by tests. - **The layer count stayed empty for a whole print, and First Layer Complete notifications read `1/0` (#2702, reporter @sn8key)** — On a P1S, a job started from Archives or through the Virtual Printer showed no layer information in the Print Status panel, and the Discord **First Layer Complete** notification went out as `1/0` instead of `1/33`. The count usually appeared some minutes into the print, which made it look intermittent, and the reporter noticed it filling in at the exact moment they submitted a bug report. **Root cause.** Bambuddy applies the printer's reported layer total as soon as it arrives, and separately clears that total when it detects a new print starting, so a leftover count from the previous job cannot become the denominator for this job's filament split. Both happen while processing the same status message, in that order — and the message announcing a new print is frequently the same one carrying the new print's layer total, so the value was stored and then immediately discarded. That is not merely late but unrecoverable: Bambu firmware sends only fields whose value has changed, so having published the total once, the printer never mentions it again. It could only come back in a full status refresh, which Bambuddy requests when it connects and when you press Force Refresh, but not during a print. Submitting a bug report happens to trigger one, which is why that appeared to fix it; and an install whose connection drops from time to time recovered by itself within minutes, which is why the fault looked random and why a *stable* connection made it worse rather than better. Everything that shows a layer count reads this single value, so the finish-photo trigger that fires on the last layer and the mid-print filament split were affected the same way. **Fix.** The new-print reset now keeps the total that arrived alongside the starting message, while still discarding anything left over from the previous job. If the starting message carried no total, Bambuddy asks for a full refresh once, and once more if layers are being laid down and there is still no total — by which point the printer certainly knows it. That is at most two extra messages per print, and never a per-layer retry. One incidental fix: an unusable layer total — `null`, or anything that isn't a number — previously raised an error out of the routine that reads status messages. Nothing above that point catches it, so the whole message was thrown away and the connection's listener stopped, leaving the printer to look silent until the staleness check rebuilt the connection. Such a value is now ignored, and the refresh described above then fetches the real total. Covered by tests for the same-message case, the case where the total arrives a message earlier, the previous job's total still being discarded, the one-shot bound, the refresh answer not re-triggering the reset, and the malformed values. - **Support bundles could contain a printer-status file that no tool could open (#2702)** — Every support bundle includes a redacted copy of the printer's last status message, so that a given model and firmware's actual field layout is available when diagnosing a report. For printers whose AMS flow-calibration factor had a particular number of decimal places, that file was not valid JSON. **Root cause.** Redaction ran over the finished text of the file, and one of its patterns recognises Bambu serial numbers by shape — a shape the digits of a decimal number can also take. A calibration value of `0.0199999995529652` came out as `0.[SERIAL]`, which is not a number, so the file failed to parse from that point on. **Fix.** Redaction now walks the structure and rewrites text values only, leaving numbers, true/false and empty values exactly as the printer reported them, so the file always parses. Nothing is redacted less thoroughly than before. Covered by tests using the value from the bundle that exposed this, and for the walk leaving keys, booleans and nested containers alone. - **External-camera timelapses and finish photos came out empty when the live view was open (#2707, reporter @bitbarista)** — On a printer with an external camera, watching the live view while a print ran meant the layer timelapse recorded almost nothing and the finish-photo notification went out with no image attached. The reporter measured zero of 87 layer captures on one print and zero of 105 on another, both watched from start to finish. A USB camera allows exactly one program to hold it open, so every capture taken during a live view failed outright rather than merely degrading. **Root cause.** Bambuddy already knew not to do this for the printer's built-in camera: a snapshot taken while somebody is watching reuses the viewer's frame instead of opening a second connection (#1348, #1271). That rule was never extended to the external-camera paths — and could not have been, because the buffered frame it depends on was only ever published by the built-in paths. The live external stream tracked when frames arrived but never kept one, and the stream hands out frames already wrapped for the browser, so there was nothing for a consumer to reuse. **Fix.** The external stream now publishes each frame as it goes past, and every one-shot consumer — layer timelapse, the finish photo and its fallback, the notification snapshot, Obico polling and the plate check — reuses that frame instead of opening a second handle. If a viewer is attached but no frame has arrived yet, that single attempt is skipped rather than competing, because kicking the viewer off is worse than missing one frame. Two things improve as a side effect: `/camera/snapshot` and the finish-photo fallback chain can now serve an external camera's live frame, where before they found an empty buffer, and the buffer is released when the last viewer of that printer leaves. Covered by tests for the frame plumbing, a buffering failure being unable to break the live stream, and each consumer in all three states — viewer with a frame, viewer without one, and nobody watching. - **A long-running camera stream could eventually stall itself, with nothing in the log to explain it (#2707)** — ffmpeg's error output was only read when something had already gone wrong, which meant that for the entire life of a working stream nobody read it. ffmpeg writes a startup banner, its analysis of the incoming video, and then a progress line at a steady rate, and the operating system only buffers a fixed amount of that before it stops the writer. Once that happened ffmpeg would block trying to write the next line, stop producing frames, and the stream's own timeout would fire — reported as `RTSP read timeout` and a reconnect, with no indication that Bambuddy had starved it. How long it takes to reach that point is unmeasured and clearly long: one stream ran 21 minutes 36 seconds without trouble, so this is a limit that was being ignored rather than a fault anyone has reported. **Fix.** A streaming ffmpeg's error output is now read continuously and the most recent portion kept, so the limit cannot be reached. The kept portion is what gets logged when a stream does fail, which is more useful than before: it holds what ffmpeg said as things went wrong, where reading the buffer on demand returned whatever it had printed first — usually the startup banner, which is then discarded as noise. Credentials are masked on this path through the same single funnel as every other camera log. Covered by tests for continuous draining, the size bound, keeping the newest output, credential masking, and the ownership handover with the shutdown path — reading the same output from two places at once is an error, so only one owner reads it at a time. - **Reopening the camera quickly could leave the new stream invisible to Bambuddy (#2707)** — Closing a camera view and opening it again straight away could leave the newly started stream unregistered, even though it was running and showing frames. The consequences were all indirect, which is what made it hard to spot: Bambuddy believed no viewer was attached, so Obico polling and snapshots would open a second camera connection and fight the live view — precisely what the guards added in #1348 and #1271 exist to prevent; the background cleanup task saw a camera process with no stream attached to it and killed the live stream as an orphan, usually within a minute; and pressing Stop reported that it had stopped nothing while the view was still running. **Root cause.** Each printer's fan-out stream was registered under a key derived from the printer alone, so every successive stream for that printer reused the same key, and the departing stream's cleanup removed whatever was registered under it — including its own replacement. The same cleanup also cleared the printer's most recent camera frame unconditionally, discarding the new stream's frame. It needed the old and new streams to overlap, which the four-second teardown fixed above made easy to hit. **Fix.** Each stream now gets its own registry key, so one stream can only ever clean up after itself — the same approach the external-camera path already uses (#2675) — and the shared per-printer frame is only released when no stream for that printer is left running. Covered by tests for both halves, including one that drives the real cleanup path with a second stream already registered. - **Closing the camera held the printer's camera connection for four more seconds, then logged an error that wasn't true (#2707)** — Every time a camera view closed, the log recorded `ffmpeg didn't terminate gracefully, killing` and then `ffmpeg did not exit within 2.0s of SIGKILL; abandoning wait`. Both waits expired every single time, so each close cost a fixed four seconds — and because Bambu firmware allows exactly one camera connection, that was four seconds in which nothing else could use the camera: reopening the view, a snapshot, Obico, or the diagnostic. **Root cause.** ffmpeg is started with its output and error streams as pipes, and the shutdown path had stopped reading them. A process whose output pipe is full blocks mid-write, and ffmpeg's shutdown signal only sets a flag that its main loop checks on the next pass, so the polite request could never be acted on and the grace period was dead time. The forced kill did work — but Python cannot report an exit while a pipe is still unread, so the second wait expired too and Bambuddy concluded the process was stuck when it had already gone. Measured at 4.00s per close before, ~0.15s after. **Fix.** Both pipes are now drained while the process is being stopped, which makes the polite shutdown effective and the exit observable. The forced-kill path and its time limit remain as backstops, so a genuinely wedged process still can't hang a stream, a Stop request, or the cleanup task. This also corrects the conclusion recorded for #2580: that 12-hour hang was the unbounded form of this same self-inflicted stall rather than a stuck ffmpeg, so bounding the wait had capped the symptom without removing the cause. Covered by tests that drive a real subprocess — the fault lives in Python's pipe bookkeeping, so a stand-in object would pass against the broken code — including one that verifies a process ignoring the polite signal still has its forced exit observed rather than abandoned. - **A crash or restart mid-print left layer-timelapse files behind forever (#2709, reporter @bitbarista)** — `timelapse_frames/` grew slowly and never shrank on its own. After several routine restarts during testing, it had accumulated 38MB: three abandoned frame directories and two 48-byte `.mp4` files from stitches that never finished writing. **Root cause.** Which timelapse session is active lives only in memory. A restart for any reason — a redeploy, a crash, a power loss — loses that bookkeeping instantly, but the frames already written for that session, and any partially-stitched output file, stay on disk with nothing left that knows they exist. `on_print_complete`'s cleanup never runs for them, because nothing calls it: the session it would clean up no longer has an entry to be found by. A restart-recovered print doesn't get a replacement session either (`_maybe_start_layer_timelapse` only fires on a fresh `PRINT_START` event, #1353), so an orphaned directory can never be resumed or claimed by anything — it just sits there. **Fix.** A one-time sweep on startup removes any `timelapse_frames//` entry that doesn't match a session that printer is currently recording or stitching, and that is old enough not to be a startup race (five minutes). Only this feature's own artifacts are swept — a frames directory or a `timelapse_.mp4` — so anything else that ends up under that folder is left alone rather than deleted for being old. Verified against the real 38MB of leftovers: startup logged each of the five removed by name, and the directory dropped to 8KB. Covered by tests for the orphaned-directory and stray-output-file cases, sparing a genuinely active session, sparing one that is mid-stitch (the window where the session has already been handed to ffmpeg and is no longer listed as active), sparing anything too recent to be sure about, leaving unrelated files alone, not counting a removal that failed as a success, and two defensive cases (no `timelapse_frames/` directory yet, an unrelated non-numeric entry under it). - **Two snapshots taken at the same moment opened two competing camera connections (#2705, reporter @gzimbric)** — Bambu firmware allows exactly one camera connection at a time. Bambuddy already knew this: a snapshot taken while somebody is watching the live view reuses the viewer's frame instead of opening a second socket. What nothing covered was two *snapshots* overlapping with no viewer attached at all — an Obico poll and a printer-wall refresh landing 200 ms apart, each correctly concluding it wasn't competing with a viewer, and then colliding with each other. On the reporter's P2S this knocked over the live stream that was feeding the camera wall, which was then reaped for having received no frames for 58 seconds. Eight paths take one-shot frames independently — Obico polling, `/camera/snapshot`, the finish-photo capture and its disk-writing sibling, plate detection, the camera connection test, and the diagnostic — so any pair of them could overlap, and a shorter Obico interval widened the window. **Fix.** Simultaneous captures for the same printer now share one connection: the first opens it, everyone arriving while it is in flight gets the same frame. Every consumer here wants "a recent frame" rather than a frame stamped at its own microsecond, so identical bytes are the right answer. This shares captures, it does not cache them — a request arriving after the previous capture finished still takes a fresh frame, because plate detection and the finish photo judge a running print from these images and a stale frame there is worse than a slow one. Each caller keeps its own deadline (they range from 10 to 30 seconds) rather than inheriting whichever one happened to open the connection, giving up alone leaves the capture running for whoever else is waiting on it, and a capture that fails doesn't hand its failure to callers that never got an attempt of their own — they retry, which by then competes with nothing. One visible consequence: when the **Diagnose** tool shares a capture this way its frame-capture stage is labelled `coalesced_capture`, because the pass is real but the timing shown is mostly time spent waiting, and a diagnostic must not report on a connection it never opened. Wiki updated. Covered by tests for the reported collision, the five-callers-one-connection case the reporter verified on live hardware, per-printer isolation, staying coalescing rather than becoming a cache, registry cleanup, a failed capture not poisoning its followers, bounded retry, a follower abandoning its wait without sabotaging the capture, and cancellation from either side. - **The same one-shot-capture collision could happen on external cameras too, with no viewer attached (#2707 follow-up, reporter @bitbarista)** — #2705 fixed simultaneous captures colliding on the built-in camera path, keyed by printer IP through `capture_camera_frame_bytes()`. External cameras reach the same kind of collision through a different function — `external_camera.capture_frame()` — that #2705 didn't touch, and a V4L2 USB device allows exactly one open handle just like Bambu's own RTSP limit. Nothing coalesced two one-shot capturers here either: Obico polling, the in-print frame bank, the finish-photo moment, plate detection and the notification snapshot could each open their own connection to the same USB camera and collide, with `is_stream_active()` unable to help since that guard only stops a capturer from competing with an *attached viewer*, not with another capturer. **Fix.** The same shape of fix as #2705, applied to `capture_frame()`: concurrent callers for the same camera (URL, type, and — since #1177's snapshot override routes to a different endpoint entirely — snapshot URL) share one capture rather than opening a second connection. Coalesces, does not cache, so a call after the previous one finishes always captures fresh. Each caller keeps its own timeout, giving up leaves the capture running for whoever else is waiting, and a capture that fails doesn't hand its failure to a caller that never got a turn of its own. One visible consequence, mirroring the label #2705 added to the built-in **Diagnose** tool: pressing **Test** on an external camera while a capture is already running now says the frame was shared with it, because the result is real but the test did not open a connection of its own — and forcing one would be the very second handle this change exists to prevent. Covered by tests mirroring #2705's: the reported-shape collision, five callers sharing one connection, per-camera and per-snapshot-URL isolation, staying coalescing rather than becoming a cache, registry cleanup, a failed capture not poisoning its followers, bounded retry, a follower abandoning its wait without sabotaging the capture, and cancellation from either side — plus, for this path specifically, that an unexpected error is reported as a failed capture rather than raised at every waiting caller at once, and that the shared-capture log lines redact credentials, since an RTSP camera URL routinely carries `user:pass@` where the built-in path's key is only an IP address. - **Auto-matched filament showed a green tick when the colour was plainly wrong (#2687, reporter @pchulpjoost)** — The Filament Mapping panel reported a slot as matched, with the header reading **(Ready)**, while the swatch beside it showed the slice wanted dark red and the tray it had picked held Dark Green. Manually selecting that very same tray from the dropdown correctly reported the colour mismatch, which is what made the disagreement so visible. **Root cause.** Auto-match ranks candidate trays by filament preset ID (`tray_info_idx`) first, and when exactly one loaded tray carried the preset the slice asked for, that tray was accepted as a *definitive* match on the assumption "same preset means same spool, so the colour must agree too". The preset ID names the **variant**, not the spool — `GFA00` is PLA Basic, `GFA01` PLA Matte, `GFA17` PLA Translucent, in every colour Bambu sells it. So a user with one Matte spool loaded matched every Matte requirement regardless of colour, and the colour comparison was never reached. This is why the report came in for PLA Matte in particular: generic PLA Basic is usually loaded several times over, which sent the match down a different path that did compare colours correctly. **Fix.** The colour verdict is now taken from the tray that was actually selected, never from which rule selected it, and the automatic and manual paths share one comparison so they cannot drift apart again. The preset still decides *selection*, because the Basic/Matte/Silk distinction matters ([#2650](https://github.com/maziggy/bambuddy/issues/2650)) — a wrong-coloured tray of the right variant is still chosen, but it is now reported as an amber **Color mismatch** instead of a green tick, and you can print anyway or pick another slot. A near-enough shade still counts as a match, and a 3MF that specifies no colour for a slot is satisfied by any colour rather than being flagged. Dispatch behaviour is unchanged: **Force color match** already required an exact colour before sending a job, so nothing was ever printed in the wrong colour because of this — the panel was simply telling you it was fine when it wasn't. Frontend-only. Wiki updated. Covered by tests for the unique-preset wrong-colour case, agreement between the auto and manual verdicts, the near-shade and colourless-requirement cases, and the multi-preset path that already worked. - **P1-series archives kept the worse finish photo when the timelapse arrived late (#2704 follow-up)** — When a print records a timelapse, Bambuddy prefers the video's last frame as the finish photo: the firmware stops recording after the toolhead parks but before the end G-code drops the bed, so it frames the finished print properly, where a live camera grab at that moment catches an already-lowered plate. Bambuddy waited 60 seconds for the video and then gave up, because the print-complete notification is waiting on that photo and holding a notification for minutes is worse than sending it with the live grab. On P1-series printers the video usually arrives later than that — they write MJPEG AVI instead of H.264 MP4 and serve it slowly, so across the support bundles their median was 33 seconds but the 90th percentile was 167 and the slowest observed was 546; every other model finished inside 26 seconds. The result was that the printers most in need of the better photo were the ones that never got it. **Fix.** The notification still goes out on the same 60-second bound with the live grab, so nothing gets slower. If the video was still on its way when that bound expired, Bambuddy now keeps waiting in the background and adds the extracted frame to the archive when it lands, at the front of the photo list so opening the gallery shows it first. The live grab is kept rather than replaced — the notification that already went out links to that exact file, and removing it would leave a broken image in Discord or Telegram. Covered by tests for the ordering, the longer budget, idempotency and the cases where the video never arrives. - **Timelapses that never got attached, and a Scan button that could not find them (#2704)** — Timelapse was on for the print, the video never arrived in the archive, and pressing **Scan for Timelapse** afterwards turned up nothing. Measured across 247 support bundles, this was not rare: of 457 automatic scans only 262 ever attached a video. **Root cause, part one.** The scan looked four times, at 5, 10, 20 and 30 seconds, then stopped. The printer writes the video only after the print ends and a long print makes a large file, so it often arrived after the last look — the attempt that found the video was the first one 272 times and then 17 / 13 / 13, a flat tail against the cutoff rather than a decaying one. What ran after those four attempts was a fallback that searched for the print's name inside the video filename; Bambu firmware only ever writes `video_`, so in 247 bundles it fired 159 times and matched exactly zero. **Root cause, part two.** The manual Scan button had no such snapshot to work from and matched by filename timestamp, by FTP modification time, or by there being exactly one video on the printer — all of which read a clock the printer cannot set, because a printer in LAN Only mode never reaches Bambu's time server. The reporter's P1S was six and a half days out, which defeats every one of those. **Fix.** The automatic scan now polls for several minutes instead of giving up after about a minute, and the name-match fallback is gone. The list of videos present when the print started is saved with the archive, so the comparison survives a Bambuddy restart mid-print and the manual Scan button can use it too — same clock-independent comparison, no timestamps anywhere. When a previous print's video lands late and two files look new, the one already attached to another archive is ruled out by name rather than by picking whichever the printer listed first, which could attach the wrong video. **Bambuddy now deletes a timelapse from the printer once it has been archived**, which keeps the printer's folder down to unclaimed videos and stops P1-series cards filling up with AVIs; your copy is in the archive, where you can watch, edit, download or remove it. That delete only happens after the transfer has been checked against the size the printer reported — which also fixes a silent truncation: an FTPS transfer that ended early produced a partial video that was attached as though it were complete. Because the first look happens seconds after the print ends — while the printer may still be writing the video — the file is also re-checked afterwards and only accepted once it has stopped growing, so a partial video is never mistaken for a finished one and the printer's copy is never removed on the strength of one. Wiki updated. Covered by tests for candidate selection, the download check gating the delete, the poll bounds, baseline persistence and the manual scan. - **A printer that refuses Bambuddy's access code now says so, instead of reconnecting silently forever (#2698, reporter @djepsylon)** — A printer whose access code or serial was wrong produced no explanation anywhere: the connection attempt was refused, and the only trace was a warning every 30 seconds reading `MQTT disconnected: rc=Unspecified error` — the same line you get from a printer that is simply switched off. The failure branch of the MQTT connect callback set "not connected" and discarded the reason code the printer had just sent, so the one piece of evidence that would have named the cause never reached the log, the support bundle, or the UI. In the report behind this fix, one of three printers had been in that loop for the entire capture and nothing said why. **Fix.** A refused connection is now logged with the printer's own reason ("Not authorized", "Bad user name or password") and, for those two, the remedy — the access code is regenerated every time LAN Only or Developer Mode is toggled, so it has to be re-read from the printer's screen. The reason is kept on the connection, so the **Connection Diagnostic**'s *Printer credentials* check now states plainly that the printer refused the credentials when that is what happened, and falls back to hedged wording when all Bambuddy knows is that there is no session — previously it asserted "the access code is most likely wrong" even for a printer that was merely rebooting or already at its connection limit. The same reason is returned by the pre-add connection test. The access code itself is never written to the log. Translated in all locales; wiki updated. Covered by tests for both refusal codes, the clearing of the reason on a successful reconnect, the diagnostic's reason plumbing, and the two UI variants. - **Camera credentials could reach the log and the support bundle, and a camera test could pass without opening a connection (#2721, contributor @bitbarista)** — Review follow-ups on the external-camera capture coalescing. That logic was transplanted from the built-in camera path, which keys on a printer IP and so has nothing to hide in a log line; these keys are camera URLs, and an RTSP URL routinely embeds `user:pass@`. Five new log lines printed the password, one of them at warning level, where it reaches support bundles. All five now redact **before** truncating — slicing first can cut the URL short of the `@` the pattern anchors on and leave the password intact, which is why every other URL log in the module already does it in that order. **Also fixed:** an unexpected error inside one capture reached every caller waiting on the shared result at once, so one caller's failure became N and none of them retried; the shared wrapper now contains it and reports a failed capture. And **Test connection** returned success for a frame it had been handed from someone else's in-flight capture — the one answer a connection test must not give silently. It still shares rather than forcing its own capture, because forcing one would open the second handle to a single-reader device that this whole mechanism exists to prevent; it now says the frame was shared with a capture already running. - **The orphaned-timelapse sweep could delete a timelapse while it was still being stitched (#2722, contributor @bitbarista)** — The sweep's own docstring claimed its age margin made it safe to run mid-stitch. It was not. A session is dropped from the active list before its frames are handed to ffmpeg, so for the length of a stitch the directory matches no active session, and its mtime is the last layer's frame write — on a tall print's final layer, easily older than the margin. The default margin and the stitch timeout were both 300 seconds, so the two were tied with no headroom at all, and a sweep landing in that window deleted ffmpeg's input from under it. An in-progress stitch is now marked explicitly, and the marker is cleared in a `finally` so a failed stitch cannot leak it and leave that printer's files permanently un-sweepable. **Also fixed:** the sweep deleted any file under its working directory past the margin rather than only the `timelapse_.mp4` shape this feature creates — nothing else writes there today, but age alone is not a reason to delete a file this feature did not create. And a removal that failed on a read-only mount or a permissions problem was counted and logged as a success, which matters because that log is the only evidence an operator has of what was deleted. - **Finish photos came out upside-down, or rotated twice, when a camera rotation was set (#2723, contributor @bitbarista)** — `camera_rotation` was only ever wired into the print-start photo and the in-print frame bank; the finish-photo pipeline saved its frames straight to disk unrotated. Fixing that surfaced a second fault: rotating the frame where it was consumed rotated one of its two sources twice, because the in-print bank's frames arrive already rotated while live grabs are raw, and the consumer cannot tell them apart. On firmware that never emits `stg_cur=22` — the path that bank exists to serve — a 180 degree rotation cancelled itself out and the photo was upside-down again, with 90 and 270 landing 180 out. Rotation now happens where each frame is captured, so every cached frame carries exactly one whatever produced it. **Also fixed:** two finish-photo sources were still writing unrotated files — the built-in camera's own capture, and the still extracted from a printer-recorded timelapse, which is the *preferred* source for a built-in camera print, so whether a photo came out the right way up depended on which source happened to win. Layer-timelapse frames are rotated too. Note that the archived video is the printer's own file and is not re-encoded, so it still plays at the camera's native orientation. A failed rotate leaves the unrotated file rather than losing a delivered photo. - **Ukrainian was listed above Russian in the language picker** — Locales appear in the picker in the order they were added, but `uk` had been inserted ahead of `ru` in the import block, the resources map and the list the picker renders. Moved to the end of all three; the alphabetically sorted supported-language list already had it in the right place. Frontend-only, with no behaviour change beyond the row order. - **Setting Spoolman options over the API with a true/false value returned a server error** — `PUT /settings/spoolman` accepts a free-form body, and sending the natural JSON form for a switch — `{"spoolman_enabled": true}` rather than `{"spoolman_enabled": "true"}` — came back as a 500 with nothing useful in it. The shipped UI always sends strings, so this only affected people driving Bambuddy from a script or a Home Assistant `rest_command`, which is exactly where a real boolean is the obvious thing to send. **Root cause.** Settings are stored as text and every reader compares them as text, but the submitted value went in untouched. Deciding whether Spoolman had just been switched on called a string operation on it, which a boolean does not have; and the raw boolean was also written straight to a text column, which SQLite quietly turns into 1/0 while PostgreSQL refuses it outright — so the stored result depended on which database the install used. **Fix.** Boolean-ish settings are now converted to a canonical `true`/`false` on the way in, accepting real booleans, `1`/`0`, and the usual spellings (`True`, `yes`, `on`) case-insensitively, since this is a documented API that scripts talk to. A value with no sensible reading, such as `"banana"`, now returns a 400 naming the field instead of being stored as-is and silently treated as off. Two details are preserved deliberately: a blank value still means "use the default" for the two options that default to on, and reading a stored value stays as strict as it has always been elsewhere in the codebase, so no existing row changes meaning. Text options are checked too, so a JSON object can no longer be stored as its own printed form. One incidental improvement: a value stored as `True` by an earlier API call showed as off in the UI, which compares case-sensitively, while the backend treated it as on — canonical storage removes that disagreement. Covered by tests across the accepted spellings, the rejected values, the blank-means-default behaviour, and the read path. ## [1.2.5.1] - 2026-07-27 ### Added - **Configure an OIDC provider from environment variables (#2593, contributor @munin92)** — Single Sign-On could only be set up by clicking through Settings → Authentication, which does not exist as a step in a deployment driven by a compose file, a Helm chart or a GitOps repository: every fresh install of the same stack needed someone to log in and re-enter the same issuer, client ID and secret by hand. One provider can now be defined entirely from `BAMBUDDY_OIDC_*` variables. It is written on startup and re-applied on every boot, so the file in your repository is the source of truth, and providers created in the UI keep working alongside it, untouched. Only the four required variables — name, issuer URL, client ID and client secret — activate it, and all four or none: a half-configured provider is refused at boot with a log line naming what was wrong, rather than appearing on the login page and failing when somebody clicks it. Values are validated by exactly the same rules the settings UI enforces, including the one that refuses account auto-linking on an unverified email address, so the environment cannot reach a configuration the UI would have rejected. A typo never stops the app from booting and never takes a working provider down: the previous configuration keeps running and the reason is logged. In the UI the provider is shown with a lock badge and no edit, delete or enable controls, because startup would overwrite any change at the next restart. Removing the variables disables the provider instead of deleting it — deleting would permanently unlink every account bound to it — and re-adding them brings it back with those links intact. `BAMBUDDY_LOCAL_LOGIN=true` remains the way back in if a misconfigured provider ever locks you out. All thirteen variables are documented in `.env.example`; wiki updated. Covered by backend and frontend tests. - **Per-file print progress inside a project (#1897, reporter @FedericoPuntelli)** — Projects that consist of many distinct files each needing N prints (e.g. 13 plates × 10 sets = 130 prints) only had aggregate progress; finding out "how many times have I printed plate_7?" meant reading the Activity Timeline line by line. Projects now take an optional **Copies per File** target: each printable file in the project's linked folders shows an **X / N** badge with a mini progress bar (gray not started, amber in progress, green target reached), and the progress card gains a **Complete Sets** bar — the minimum per-file count, i.e. how many finished assemblies you can ship right now. Without the new target, files simply show a printed-count badge (3×). Counting matches the aggregate stats: only completed runs, attributed to a file by a new `library_file_id` stamp on queue-dispatched archives, with content-hash and filename fallbacks covering historical prints. Also fixed along the way: **files queued from a project-linked File Manager folder now attribute their prints to that project** — previously only prints started from the project page counted toward project statistics. Translated in all locales; wiki updated. Covered by backend and frontend tests. - **Users can now delete empty folders in the File Manager (#1781, reporter @cadtoolbox)** — Library folders have no ownership tracking, so folder deletion was gated entirely behind `library:delete_all` — a regular user with `library:delete_own` could create folders and delete their own files, but the emptied folder sat there until an admin removed it. Users with `library:delete_own` can now delete folders that are truly empty: no subfolders, no files — including trashed ones, since deleting a folder would silently drop another user's trash-restorable files. External folders (operator-configured mounts) and folders linked to a project or archive still require `library:delete_all`, even when empty. The folder tree's Delete entry enables accordingly, with a "You can only delete empty folders" tooltip on non-empty ones; the bulk-delete API applies the same rule. Translated in all locales; wiki updated. Covered by backend and frontend tests. - **AI failure detection is now visible on the printer cards (#1546, reporter @Jeff-GebhartCA)** — Previously the live Obico classification (safe / warning / failure, smoothed score) was only visible under Settings → Failure Detection, so tracking how detection matched an ongoing print meant flipping between the Printers screen and Settings. Each printer card's badge row now shows an AI badge whenever detection is enabled for that printer, like the other health badges: gray **Idle** while no print is being watched, then green **Safe**, amber **Warning**, or red **Failure** while a print is actively monitored. The tooltip carries the current score, and clicking opens a modal (like the HMS error badge) with the live status, score, frames analyzed, and the detection service's last error — plus a shortcut to the full settings. Toggling detection on or off updates the cards immediately. Printers excluded from monitoring and setups without failure detection show nothing. Served by a new lightweight `/obico/printer-status` endpoint readable with printer permissions alone (the existing settings-gated endpoint is unchanged and keeps configuration private). Translated in all locales; wiki updated. Covered by backend and frontend tests. - **Bark is now a notification provider (#1495)** — [Bark](https://github.com/Finb/Bark) is the open-source, account-free iOS push app (self-hostable via bark-server), popular especially with Chinese-speaking users. Configure it with just the device key from the app; the server URL defaults to the official `api.day.app` relay and accepts a self-hosted instance. Optional settings: notification **Group**, **Sound**, and iOS **Interruption Level** — Time Sensitive breaks through scheduled summaries, Critical bypasses Silent mode and Focus (useful for print-failure alerts), Passive delivers silently. Send failures wrapped in an HTTP 200 body by bark-server are detected and reported properly. Translated in all locales; wiki updated. Covered by backend and frontend tests. - **Home Assistant notifications can carry custom data fields (#1441)** — When a notification provider targets an HA notify service (e.g. `notify.mobile_app_myphone`), a new optional **Data (JSON)** field is forwarded as the service call's nested `data` object — the same place HA automations put mobile push options like `priority`, `ttl`, `channel`, and `group`. `ttl: 0` + `priority: high` make Android pushes arrive immediately instead of batched, and `channel` gives printer alerts their own notification channel/sound. The field is JSON (not key=value lines) so numbers stay numbers (`ttl: 0`) and nested options work. Validated on both ends: the UI rejects malformed JSON before saving, and the sender fails loudly with a clear message rather than posting a half-built payload. Only included when configured — the default persistent-notification path is unchanged, as its schema rejects unknown keys. Translated in all locales; wiki updated. Covered by backend and frontend tests. - **Energy usage now feeds the statistics that previously only knew about filament (#1432)** — Bambuddy has measured per-print energy via an attached smart plug for a while (the plug's lifetime counter is captured at print start and the delta stored with the print), but two stats surfaces ignored it. First, the **Most Expensive** record on the Statistics page ranked prints by filament cost alone, so a cheap-filament print with hours of heated-chamber time could never win; it now ranks by filament + measured energy cost (prints without a smart plug simply compete on filament cost, as before). Second, **Filament Trends** gained an **Energy Over Time** chart — per-day kWh (per-hour for short ranges, per-week for long ones), with the range's total kWh and energy cost in the header. The chart only appears when the selected range actually contains measured energy data, so setups without smart plugs see no change. The `/archives/slim` stats feed now carries each run's `energy_kwh`/`energy_cost`. Translated in all locales. Covered by backend and frontend tests. - **The plate-clear gate is now visible over MQTT, and can raise a notification (#2525, reporter @daschaefer)** — When a print reaches a terminal state, Bambuddy holds the queue until someone confirms the build plate is clear. That gate was visible only in the Web UI: the printer's own MQTT push reports nothing beyond `RUNNING`/`PAUSE`/`FAILED`/`FINISH`/`IDLE`, so an external automation could not tell "finished" from "finished and still waiting for a human". The per-printer status topic now carries an **`awaiting_plate_clear`** field, and every transition is additionally published on a new **retained** topic `bambuddy/printers/{serial}/plate_clear` (`{"awaiting": true|false, …}`). Retained and published from the flag itself rather than from printer telemetry, so a subscriber learns the current state of every printer the moment it connects — and the state stays correct after Auto Off powers a printer down, which stops telemetry entirely and would otherwise leave the status topic frozen at `false`. Publishing is edge-triggered: the queue re-asserts the flag on every dispatch, and no subscriber should see a "plate cleared" for a plate that was never dirty. A matching **Plate Clear Required** notification event was added, off by default on every provider because it fires after every print at the same moment as the print-complete alert. Acknowledging still goes through the existing `POST /printers/{id}/clear-plate`. Translated in all locales; wiki updated. Covered by backend tests. - **Re-slicing a model designed for another printer can now keep the designer's print settings (#2622, reporter @kpp39)** — Published models often deviate from the stock Bambu profile on purpose: five walls, 100% infill, a 0.1mm first layer. Re-slicing one for a different printer discarded all of it, because the picked process preset overrides the file's embedded settings — that override is exactly what makes cross-printer re-slicing work, so it could not simply be dropped. "Slice as designed" (#2611) was no help here: it is all-or-nothing and only offered when your printer already matches the design's target. The slice dialog now shows a **Keep the designer's settings** panel listing precisely which print settings the author changed away from the stock profile, and what each was set to, with a checkbox per setting. **Design-intent settings** — wall count, infill density and pattern, layer and first-layer height, supports, seam position, brim, ironing — are ticked by default. **Printer-specific ones** — every speed and acceleration, jerk, fan speeds, temperatures, prime-tower geometry — are listed with a badge but start unticked, because a value tuned for the author's machine can be merely wrong on yours or outside the range your printer's profile accepts, which fails the slice outright. Nothing is guessed: Bambu Studio records the deviating-settings list inside the 3MF itself, so the panel shows the author's own change list. Only ticked settings are sent, only the process slot is carried (your filament picks are untouched), and the panel is hidden for files that change nothing. Translated in all locales; wiki updated. Covered by backend and frontend tests. - **The size-S printer card now shows remaining time, ETA and layer progress (#2674, reporter @jakestatefarm1101-alt)** — Size S existed for exactly one job: watching a whole fleet on one screen. But it rendered only the printer name, a status pip and a progress bar — every other block on the card is gated behind the expanded view — so it could not answer the question that view is for, "which printer finishes first". Dropping to S to fit more printers meant losing the information you dropped down to compare. The compact card now carries one line of metrics under the progress bar while a print is running: **remaining time**, **ETA** in your configured 12/24-hour format, and **layer progress** — the same values the Medium card already shows, using the same formatters and the same ETA styling so the two read alike. Each value is omitted individually when the printer doesn't report it, and the row holds its height when nothing is printing so cards don't shift as prints start and finish. Card dimensions and grid density are otherwise unchanged. Frontend-only. Wiki updated. Covered by tests. - **Failure detection can now authenticate to a token-protected Obico ML API (#2733)** — Obico's `ml_api` container takes an optional `ML_API_TOKEN` environment variable; with it set, the container answers 401 to any detection request that doesn't carry that token, which is how you stop everything else on your network from using your inference server. Bambuddy never sent one, so the only way to use it was to remove the token from the server — a step the reporter had already taken for their Home Assistant setup and did not want to undo. **Settings → Failure Detection** now has an **ML API Token** field; leave it empty and requests go out exactly as before. Also worth knowing: this failed in the most confusing way possible, because Obico protects its detection endpoint but leaves its health endpoint open. Bambuddy's **Test** button pinged the open one, so it reported success against a server that was rejecting every real call, and detection just silently never fired. Test now checks both and says outright when a token is rejected, and the status card reports a rejected token as a rejected token rather than a bare HTTP error. Translated in all locales; wiki documents the token, the health-endpoint trap and how to recover from it. ### Fixed - **Support bundles reported Obico as monitoring no printers when it was monitoring all of them (#2733)** — The bundle read the list of monitored printers with a parser that split on commas and treated an empty value as "none". The setting is a JSON array, and empty means *all printers* — which is the default. So a working default Obico setup showed `obico_enabled: false` against every printer in its own support bundle, sending anyone reading it down the wrong path. The bundle now parses the setting the way the detection service does, and takes the global on/off switch into account. - **Slicing a single-plate project failed on filament slots the plate never prints with (#2711, reporter @kpp39, also seen by @phi-schi)** — Sending a MakerWorld project to the slicer was rejected with "filament preset ... (slot 1) is not compatible with printer ...", naming a slot the model doesn't use, and the slice modal deliberately locks the dropdowns for unused slots so there was no way to correct it by hand. **Root cause.** A project can declare more filaments than any one plate paints with — the reported model declares four and uses one — and the slicer validates every filament it is handed, not just the ones the print touches. Bambuddy already rewrote those unused entries to match a slot the plate really uses, but only when the plate number was part of the request. The modal omits it for single-plate projects, since there is no plate to choose, so the rewrite never ran for them — which is every model imported from MakerWorld. The three idle slots therefore arrived carrying whatever the source file had baked in, in this case profiles for an entirely different printer, and the slicer refused the job on the first one. **Fix.** A missing plate number now means the first plate, which is what it means everywhere else in the slicing path, so single-plate projects get the same treatment multi-plate ones already had. **Also fixed:** "Slice all plates" reached the same code, where it isn't a plate number at all. On a project with a dedicated support filament that combination could rewrite every colour to the support material and silently slice a multi-colour model in one filament — it is now excluded, since across all plates no slot is unused. Covered by tests for a single-plate slice with no plate number in the request, for slice-all leaving every slot untouched, and for the support-filament case specifically. - **Bambuddy can be configured to want more database connections than PostgreSQL will give it** — The connection pool's own ceiling is 100 per worker process by default, while a stock PostgreSQL allows 100 in total and reserves 3 of those for administrators. Nothing checked the two against each other, so the mismatch only appeared as a failure somewhere unrelated once the connections ran out — which is how the dispatch failure above happened. **Fix.** Bambuddy now compares the two at startup and, when the pool could ask for more than the server allows, logs a warning naming both numbers, how many connections are already open, and the settings to change. Both figures are also included in the support bundle, so it is possible to tell a misconfigured limit apart from connections being held too long. Nothing is adjusted automatically: the pool is sized before any connection exists to ask the server with, and the right ceiling depends on how many worker processes you run and what else shares the server. Unchanged on SQLite, which has no such limit, and a server that declines the question is ignored rather than delaying startup. Covered by tests for the warning content, the reserved-slot arithmetic, the values reported to the bundle, and startup surviving a refused probe. - **Spools added as stock could not be edited or duplicated without assigning a slicer preset, and that preset then overwrote the spool's own manufacturer (#1905, reporter @rocstat1979)** — A spool created via **Quick Add**, a CSV import or an RFID scan has no slicer preset, brand or subtype. Reopening it in **Edit Spool** demanded all three before anything could be saved — so changing the storage location, the cost or the note was impossible — and **Copy Spool** had the same gate with no Quick Add toggle to waive it. Worse, the preset you were forced to pick auto-filled material, brand and subtype from the preset name, silently rewriting a hand-entered manufacturer ("Elegoo" → "Generic") so the spool no longer appeared where it had been filed. **Fix.** Editing and copying now require only what the backend requires — the material; the preset, brand and subtype fields stay fully visible and editable (nothing is hidden the way Quick Add hides it), and the required-field markers no longer advertise a rule that isn't enforced. Selecting a preset now fills only fields that are still empty or that a previously selected preset had filled, so values you (or the saved spool) provided survive; switching between presets still replaces what the earlier one contributed. **Also fixed:** the brand and material dropdowns no longer *filter* themselves down to the brand/material pairs known to the color catalog and slicer presets — a real combination like Elegoo ASA looked impossible to enter because Elegoo was catalogued only for PLA. Both lists now always offer everything known, with the catalog-paired entries ranked first under a **Suggested** heading and the rest under **All**, and a spool's own custom brand or material is always present in its dropdown. The same change applies to the SpoolBuddy write-tag form, which shares these fields. Lastly, the Quick Add layout no longer leaks out of create mode: quick-adding a spool and then opening Edit used to strand the edit form in the reduced layout with no toggle to leave it. Frontend-only. Translated in all locales; wiki updated. Covered by validation and form-interaction tests. - **The spool PA-Profil (Pressure Advance) picker only ever offered the 0.4mm K-profile, hiding nozzle-specific profiles for the same filament on multi-nozzle printers (#2618)** — When a printer had two K-profiles for one filament differing only in nozzle size (e.g. PAHT-CF at 0.4mm K=0.042 and 0.6mm K=0.028), the **Edit Spool → PA-Profil** tab (and the SpoolBuddy write-tag page, which shares the picker) showed only the 0.4mm entry ("1 match, K=0.042"), regardless of the nozzle actually installed. **Root cause.** Both surfaces fetched a printer's calibrations with `getKProfiles(printer.id)`, which defaults the nozzle filter to `0.4` — and the printer/MQTT layer filters strictly by that diameter, so the 0.6mm profile was never retrieved. (The AMS-Slot config dialog was already fixed for this in #1899; these two pickers were not.) **Fix.** The picker now queries every nozzle the printer reports installed (`0.4`, `0.6`, …) and merges the results, falling back to `0.4` only when the printer hasn't reported its nozzle hardware. Each profile row now also shows a nozzle-diameter badge so two identically-named profiles are distinguishable. Frontend-only. Covered by tests for the nozzle enumeration and the two-profile rendering. - **Print-archive backups to a Gitea or Forgejo instance hosted under a URL path prefix could not be configured — the repository URL failed to parse (#2642, reporter @M1ndHunteR)** — Self-hosted Gitea/Forgejo is often served under a subpath (`ROOT_URL` like `https://host/gitea`), so repositories live at `https://host/gitea/owner/repo` rather than at the host root. **Root cause.** The Gitea backend (shared by Forgejo) assumed the repo sat directly under the host: URL parsing required exactly two path segments after the hostname, so a subpath URL's three segments (`gitea/owner/repo`) matched nothing and raised "Cannot parse repository URL". Even had it parsed, the API base was derived from scheme+host only, yielding `https://host/api/v1` instead of `https://host/gitea/api/v1`, so every API call would have 404'd. **Fix.** The Gitea/Forgejo backend now treats the final two path segments as `owner`/`repo` and keeps any leading segments as a base-path prefix, deriving the API base as `{scheme}://{host}{prefix}/api/v1`. Root-hosted instances are unaffected (empty prefix). GitHub/GitLab are untouched. Covered by parse and API-base tests for both providers. - **The Print Queue's History tab showed a count of all prints but only ever displayed the first 50, with no way to reach the rest (#2682, reporter @pchulpjoost)** — The History header read e.g. `History (311 items)`, but only 50 rows rendered and there was no "load more" control, so 261 finished prints were unreachable. **Root cause.** The full history is already loaded client-side (the queue endpoint has no limit) and sorted correctly — the header counts the whole list — but the row builder hard-sliced it to `items.slice(0, 50)`, a fixed cap with no accompanying control. Nothing was missing server-side; it simply wasn't drawn. **Fix.** History now paginates: it draws the 50 most-recent prints and, when there are more, shows a **Show more** button (with a `Showing X of Y` count) that loads the next 50, repeating until the whole history is on screen. The page size resets to the first page only when you re-sort or change the location filter — deliberately not on the periodic queue poll, so an expanded view doesn't collapse mid-scroll. Frontend-only; batch grouping and per-row actions are unchanged. Covered by a test asserting the 50-row cap, the `Showing 50 of 60` count, and that Show more reveals the remainder. Wiki updated. - **LDAP Distinguished Names weren't redacted from the support bundle / bug report (#2681, reporter @MaxBareiss)** — With LDAP auth in use, the debug log carried lines like `LDAP authentication successful for user: … (DN: CN=Joe Schmoe,CN=Users,DC=ad,DC=example,DC=com, …)`. A DN's leaf `CN` is the user's real name — PII on par with the email address Bambuddy already redacts — and it passed straight through into an uploaded support bundle. **Fix.** The log sanitizer (used by both the support bundle and the in-app bug report) now redacts LDAP DNs to `[DN]` wherever they appear — the auth line, ldap3 exception strings, and group DNs alike — matching a run of `attr=value` RDN components (`CN/OU/DC/UID/…`) so ordinary `key=value` log text isn't affected. As primary hygiene the LDAP service also no longer logs the raw DN on successful auth (the username plus group count is enough). Covered by tests, including the exact reported line and non-DN `key=value` lines that must be left intact. Redaction list on the Bug Report wiki page updated. - **An external USB camera could stay locked (LED stuck on) after closing the live view, blocking reopen (#2675, reporter @bitbarista)** — Closing an external USB (V4L2) camera's live view abruptly — tab/popup closed, or a dropped connection — could leave the backend's `ffmpeg` process running and holding `/dev/videoN` open. The camera LED stayed lit and the next attempt to open the view (or click Test) failed or took 10-30+ seconds while the new `ffmpeg` fought for exclusive device access. **Root cause.** This is the same class of leak as #776 (fixed for the built-in RTSP path), but the external/USB path was never wired into that fix. #776 added the `_active_streams` / `_disconnect_events` / spawned-PID registries so both the `/camera/stop` endpoint and the periodic orphan janitor could find and kill leaked ffmpeg — but external streams registered into none of them, so for USB cameras both were structurally blind: `/camera/stop` returned `{"stopped": 0}` even while a stream was genuinely running, and the janitor's `/proc` net matched only `rtsp(s)://bblp:` cmdlines, never a USB `ffmpeg`. Cleanup ran only via the stream generator's own `finally`, which an abrupt disconnect can skip. **Fix.** External USB (and external-RTSP) streams now register their `ffmpeg` process into the same registries the built-in path uses, so `/camera/stop` terminates them promptly (now `{"stopped": 1}`) and the janitor reaps any that leak within its cleanup interval. The `/proc` safety-net scan also now recognises USB (`-f v4l2`) `ffmpeg`, so orphans surviving an app restart are caught too; a leaked process that hangs on a still-locked device (rather than exiting) is registered before the startup probe so it can still be killed. Covered by tests: the stream hands its process to the registry, the stop endpoint and janitor both reap a registered external stream, and the `/proc` scan matches `v4l2` while ignoring unrelated `ffmpeg`. Thanks to @bitbarista for the precise diagnosis. (Reported alongside a working fix; implemented here.) - **A broken slicer sidecar silently produced tiny corrupt files that were queued and printed anyway, and a reverse-proxy 413 wasn't self-explanatory (#2671, reporter @Austinzveare)** — With the slicer-API sidecar behind a reverse proxy, slicing produced ~28-byte files that "did nothing" (and could still be sent to the printer), while a separate proxy attempt failed with a bare **413 Request Entity Too Large** that the recommended nginx fix didn't seem to resolve. **Root cause.** Bambuddy's slice client only validated the sidecar's HTTP *status*, not its body. When the sidecar — or a proxy in front of it — returned `200 OK` with a body that wasn't a real 3MF (a stock/misconfigured sidecar, a proxy error page, a truncated response, or an OrcaSlicer/Bambu Studio CLI crash that emitted no output), Bambuddy wrote that tiny blob straight to a `.gcode.3mf`, stored it as a valid sliced file (the 3MF-parse failure was swallowed as merely "no thumbnail"), and let it be queued and FTP'd to the printer. Separately, a genuine 413 comes from the reverse proxy in front of the sidecar rejecting the multi-MB upload (model + profiles), not from the slicer — so raising the body limit on the wrong proxy layer had no effect. **Fix.** The slice client now validates the sidecar's output: when a 3MF export was requested, the response body must be a real ZIP (3MF container) or the job fails loudly with an actionable message ("…the body is not a valid 3MF (N bytes) — check the sidecar URL and any proxy in front of it") instead of persisting a corrupt file. A 413 now yields a targeted message naming the fix — raise `client_max_body_size` (or equivalent) on the proxy directly in front of the sidecar. Covered by tests: a 200 with a non-3MF body raises a server error (both the profile and embedded-settings paths), a 413 surfaces the reverse-proxy guidance, a valid 3MF still slices, and raw-gcode preview output is not zip-validated. Wiki troubleshooting updated with both scenarios. - **File Manager "sort by recent activity" didn't match `ls -t`, and there was no way to see a file's modified date (#2680 / #1770 follow-up, reporter @Kingbuzz0)** — For external (mapped/NAS) folders the folder tree's activity sort and the file pane's date sort put things in a seemingly random order — some entries roughly right, most not — instead of the real newest-first order shown by `ls -t` or Windows Explorer. **Root cause.** Nothing captured the files' actual on-disk modification time. The sort keyed off Bambuddy's own database `updated_at`/`created_at` timestamps, which for a bulk external scan are all the same instant (the scan time), so a whole block of files tied and sorted arbitrarily; only the few rows Bambuddy had later touched individually looked "partially correct." The folder tree also only bubbled up *immediate* child-file activity, so a file added deep in a subtree never lifted its parent folders. **Fix.** External scans now record each file's and each directory's real filesystem mtime (`os.stat().st_mtime`), refreshing it on every re-scan so a file edited over the mount re-sorts correctly. The folder tree's "recent activity" is now a **recursive** newest-descendant roll-up — a freshly-added file anywhere inside a folder lifts every ancestor — and both the tree sort and the file pane's date sort use the real mtime (falling back to `created_at` for managed uploads that have none). A new toolbar toggle shows/hides each item's **last-modified date** in the right-hand pane (grid and list views), and the same toggle puts a date on every row of the folder tree as well, nested folders included. Folders show **last activity** rather than "last modified" — the newest timestamp among the folder itself, its files and everything below it, which is exactly the value the tree sorts on — so a folder holding a file you touched an hour ago reads as an hour old even though its own directory mtime is older. Folders with no activity at all show no date instead of a placeholder. Existing external folders backfill their mtimes on the next scan. Covered by tests: scan captures real file/folder mtimes, a re-scan refreshes a changed file, and a deep file bubbles its subtree's root ahead of a sibling with only a middle-aged file. - **An AMS-HT slot kept showing the removed filament and never cleared (#2670, reporter @needo37)** — After the #2594 fix, every empty-slot clearing path skipped AMS-HT units, so once a spool was removed the HT slot on the printer card stayed stuck on the old filament (Bambu Studio correctly showed it as Empty). The root cause was the HT's presence signal: firmware reports it as a single consecutive bit in `tray_exist_bits` at `16 + (ams_id − 128)` (HT-A = bit 16, HT-B = bit 17, …), not the regular `ams_id × 4` position — so the bitmask cleanup skipped the HT entirely, and the HT's `state` field is firmware-variant and can't be used instead. Confirmed against a live H2D capture (loaded HT reports the bit set, empty reports it clear) and cross-checked with the OrcaSlicer reference. **Fix.** The bitmask cleanup now understands the HT's real bit position and clears an empty HT slot the same way it clears a regular one, using firmware's own authoritative presence bit — so a loaded HT is never wrongly cleared (its bit stays set, keeping the #2594 fix intact). The AMS change detection now hashes the merged state, so a removal signalled only by the bitmask still unbinds the slot's spool assignment; and the websocket status now carries the presence bit so the card renders "Empty" (not "?") consistently. Verified for both single- and dual-HT setups. - **The print dialog clipped the per-filament gram usage when the material name was long, especially on mobile (#2669, reporter @apizz)** — In the Print dialog's Filament Mapping, each required filament shows its name and the grams the job needs, e.g. `Bambu PLA Basic (281.2g)`. The name and the gram figure lived in a single fixed-width column that truncated as one unit, so a long name (e.g. `Polymaker PLA Matte`) pushed the `(…g)` off the end and cut it off — partially on a wide screen, entirely in mobile portrait. The gram usage is the more important number here (it's what tells you whether a spool has enough left), so hiding it was the wrong thing to drop. **Fix.** The gram usage is now pinned and never shrinks or truncates; only the material name truncates (with the full name on hover), so the `(…g)` stays fully visible at every width. Applied to both the Specific-Printer and "Any [model]" mapping panels. Frontend-only, no behaviour change beyond layout. Covered by a test asserting the gram figure renders in its own non-truncating element separate from the truncating name. - **A printer's nozzle size got overwritten to the wrong value (often 0.8mm), then blocked prints as a nozzle mismatch (#2663, reporter @huykent)** — A1 printers with a 0.4mm nozzle intermittently showed **0.8mm** (or no size at all) on the dashboard, and since 1.2.5 that wrong value made the nozzle-mismatch guard (#1899) refuse to dispatch the job — "File sliced for a 0.4mm nozzle, but the printer has 0.8mm installed." It was intermittent and could flip *after* a job was sent. **Root cause.** Bambuddy fetches K-profiles by probing every nozzle size in turn — it sends an `extrusion_cali_get` request for 0.2, 0.4, 0.6 **and** 0.8mm. The printer's response to each echoes the *requested* nozzle diameter at the top level, and the MQTT handler passed every `print` message — including these K-profile responses — through `_update_state`, which treats a top-level `nozzle_diameter` as the installed hardware. So the last size probed (0.8) clobbered the real nozzle size in memory; a later genuine status push would correct it, and the next K-profile fetch would break it again, which is why it flickered and "changed after the job was sent." The raw MQTT status always reported the correct 0.4 — only the derived hardware-nozzle field was corrupted. **Fix.** `extrusion_cali_get` responses are now handled *only* by the K-profile parser and no longer fed to `_update_state`, so they can't touch the nozzle hardware state — mirroring the existing guard that already stops `get_accessories` responses from doing the same thing. The installed nozzle size now comes solely from the printer's real status push, where it was always correct. No configuration or migration needed: the value lives in memory and self-corrects on the next status push after updating. Covered by tests: a 0.8mm K-profile response leaves a 0.4mm nozzle untouched, the response's profiles are still parsed into `state.kprofiles`, and a genuine status push still sets (and corrects) the nozzle. - **The print queue couldn't be reordered on a phone, and the reorder controls were invisible in portrait (#2667, reporter @aporlebeke)** — On mobile there was no way to reorder the queue: in portrait the reorder controls simply weren't visible, and even in landscape (where the desktop drag handle appears) touch-dragging didn't move anything. **Root cause.** The drag grip and selection checkbox on every pending row are `hidden sm:flex`, so below the 640px breakpoint (phone portrait) they disappear entirely — there's no affordance to grab. Above it (landscape phone/tablet) the grip shows, but it carried `touch-action: manipulation` and the only drag sensor is dnd-kit's `PointerSensor` with an 8px activation distance, so on touch the browser claimed the vertical gesture as a scroll before the drag ever started. The whole reorder mechanism was effectively mouse-only. **Fix.** Pending rows now get tap-friendly **up/down arrow buttons** on mobile (the "arrow select" the reporter asked for), shown below `sm` where the drag handle is hidden. They move a row one step among its siblings — standalone items, whole batches, and items within a batch, in both the flat and per-printer layouts — and persist through the same `POST /queue/reorder` path as drag, so arrows and drag agree. Arrows appear only in the manual "position" sort (with shortest-job-first off), where a position actually has meaning, and are gated on the same `queue:reorder` permission; the up arrow on the first row and the down arrow on the last are shown disabled. Separately, the desktop drag handle's `touch-action` is now `none`, so mouse-style drag also works on touch (landscape phones, tablets). Reuses the existing `queue.moveUp` / `queue.moveDown` translations (already present in all locales). Covered by tests: the controls render for pending items, moving the first item down persists the swapped order, and the boundary arrows are disabled. - **3D Preview plate thumbnails were broken (401) in File Manager when login was enabled (#2661, reporter @fbordonaro)** — Opening a multi-plate 3MF via **File Manager → 3D Preview** showed broken-image icons for every plate thumbnail, and the network tab showed `GET /api/v1/library/files//plate-thumbnail/` returning **401 "Valid camera stream token required."** The Slice dialog displayed the same file's thumbnails correctly, which is what made it look inconsistent. **Root cause.** The plate-thumbnail endpoints (both archive and library) are gated behind a **camera stream token** passed as a `?token=` query param, because an `` tag can't send an `Authorization: Bearer` header. Every place that renders these thumbnails is supposed to append the token via the `withStreamToken()` helper — `PlatePickerModal` (the Slice dialog's multi-plate picker) and the Print modal's `PlateSelector` both do — but the **3D Preview dialog** (`ModelViewerModal`) rendered the raw `thumbnail_url` with no token, so with auth enabled the browser fetched without one and got a 401. **Fix.** `ModelViewerModal` now wraps the plate thumbnail `src` in `withStreamToken()`, matching the two existing call sites. The token is already synced app-wide (the same global the working pickers read), and `withStreamToken()` is a no-op when auth is off, so nothing changes for non-auth setups. Covered by a component test asserting the plate thumbnail `` carries the `?token=` query param. - **Force color match dispatched a print onto the wrong PLA variant — Matte jobs went to Basic and Silk printers alike, and the wrong AMS slot on a printer holding two same-colour variants (#2650, reporter @MartinNYHC)** — With **Force color match** on, a job sliced for **White PLA Matte** was dispatched to every printer that had *any* white PLA loaded — the ones holding White PLA **Basic** and White PLA **Silk+** included — so a matte model came out glossy on the wrong machine. **Root cause.** Bambu's MQTT status reports every PLA sub-variant as `tray_type == "PLA"`; the Basic/Matte/Silk distinction is carried only in `tray_info_idx` (`GFA00` = Basic, `GFA01` = Matte, `GFA06` = Silk, …), which the 3MF's `slice_info.config` also records per filament. Three places dropped it: the Virtual-Printer queue built each force override as `{slot_id, type, color, force_color_match}` without the parsed `tray_info_idx`; the scheduler's eligibility check (`_get_missing_force_color_slots`) compared loaded trays on `(type, colour)` only — so `(PLA, #FFFFFF)` matched Basic, Matte and Silk indiscriminately and all three printers looked eligible; and the AMS slot mapper cleared `tray_info_idx` when applying the override, so even on the correct printer it could pick a different-variant tray of the same colour. **Fix.** The force override now carries the 3MF's `tray_info_idx`; a slot counts as satisfied only when a loaded tray matches type **and** colour **and** the variant (identical `tray_info_idx`, *or* either side lacks one); and the slot mapper now keeps the variant for force-colour overrides so it pins the matching tray. A blank idx on either side (custom/third-party spools report none, and older 3MFs carry none) falls back to the historical type+colour behaviour, so those setups are unaffected, and a manual filament *swap* (a preference override) still clears the idx so it matches the swapped-in spool rather than the old one. A job sliced for GFA01 now goes only to a printer with GFA01 loaded, and lands on that printer's GFA01 tray. The printer-card queue-compatibility hint (which printers show a pending job as runnable) now applies the same variant rule. Covered by scheduler tests (Matte requirement rejects Basic/Silk, accepts Matte, blank loaded idx falls back, requirement without an idx unchanged; the mapper pins the GFA01 tray over a same-colour GFA00 on both the 3MF and no-3MF paths; a preference swap still matches by colour), a Virtual-Printer test asserting the override carries `tray_info_idx`, and frontend tests for the variant-aware queue hint (rejects other variants, accepts the match, blank-idx and no-variant-data fall back). ### Security - **Patched two build-time frontend dependencies flagged by `npm audit` (GHSA-r28c-9q8g-f849, GHSA-mh99-v99m-4gvg)** — `postcss` 8.5.15 → 8.5.23 fixes a path traversal in its source-map auto-loader (`sourceMappingURL`) that could disclose arbitrary `.map` files, and `brace-expansion` (pulled in transitively by `eslint` via `minimatch`) is bumped through the existing `overrides` block (`^5.0.7` → `^5.0.8`) for a denial-of-service via unbounded expansion. Both are build/lint-time tooling only — neither is part of the shipped app, so no running Bambuddy install was exposed. `postcss` moved within its existing range; `brace-expansion` needed the pin because `npm audit fix` can't lift `eslint` to the patched transitive on its own. - **Pinned `react-router` to its most-patched 7.x (7.18.1) and documented the one remaining, unreachable advisory (GHSA-qwww-vcr4-c8h2)** — Staying current on the 7.x line matters: 7.18.1 clears 14 advisories that older 7.x releases carry, several reachable in a browser SPA (open-redirect XSS in ``/`useNavigate`, route-matching DoS). The single advisory that still flags 7.18.1 — a CSRF bypass — applies only to React Router's **RSC mode**, which requires the server runtime (`@react-router/server`, not installed); Bambuddy is a Vite SPA using `BrowserRouter`, so the vulnerable path is unreachable. There is no non-major fix (the patch landed only in the 8.3.0 major, and `react-router-dom` has no 8.x — adopting it would mean migrating every import to `react-router` plus a React peer bump), so `react-router`/`react-router-dom` are pinned to 7.18.1 and the finding is carried as a documented, fail-closed exception in the CI audit gate: a *different* react-router advisory still fails CI, and the exemption is dropped automatically the moment a non-major fix ships. `npm audit fix --force` is deliberately avoided — its suggested "fix" is a downgrade to 7.11.0, which reintroduces those 14 advisories. ## [1.2.5] - 2026-07-24 ### Added - **Skip Objects can now be selected directly on the top-down build plate** — The skip dialog pairs the plate preview with the slicer's exact per-object pick mask, so clicking a model selects the same object id the printer firmware expects. Multiple objects can be selected before one confirmation, selected and already-skipped items are highlighted on the plate, and the checklist remains available when a pick mask is missing. Selecting every remaining object keeps the printer's existing stop-print warning, and the dialog closes once the skip is confirmed. Confirming names the object when one is selected and counts them when several are, which is what plates of identically-named clones need. The existing layer, permission, and printer-command guards are unchanged. - **Assigning a spool to an AMS slot now tells you whether the printer actually accepted it (#2582, reporter @gyrene2083)** — Until now, assigning a spool to an AMS tray was fire-and-forget: Bambuddy pushed the filament setting to the printer and immediately reported success, whether or not the tray took it. When the assignment silently didn't land — the reporter's case, where a spool assigned in Bambuddy never showed up in Bambu Studio — nothing told you, and the only way to tell it had loaded was to run a flow calibration and watch for the K-profile to appear. Because a print only deducts filament from the spool assigned to the *exact* tray it pulls from, a silently-dropped assignment also meant that print recorded no filament usage, which is what made the whole thing feel random. Bambuddy now reads the AMS telemetry back after every assignment (from both **Printers → assign spool** and **Configure Slot**) and toasts the outcome: **"Filament loaded on slot X"** once the tray echoes back the filament id that was pushed, a warning if the filament loaded but the **flow-calibration (K-profile) wasn't applied**, or **"couldn't confirm the assignment — check the AMS slot"** if the tray never reflects it within ~30s. The confirmation is derived entirely from the periodic status the printer already sends (an on-demand pushall is nudged so it lands quickly), covers regular AMS, AMS-HT, and external-spool slots, and if the printer goes silent it simply stays quiet rather than inventing a failure. No configuration; the toast appears automatically on assign. - **Bed levelling, flow calibration, and nozzle-offset calibration now have an "Auto" option, matching Bambu Studio** — These three print options were previously on/off only, so the only way to run bed levelling was to force a full level before every print. Bambu Studio has long offered a third "Auto" state that lets the printer skip the calibration when it was done recently, and that state is what most people actually want. All three options (in the Schedule/Print dialog, the queue bulk-edit, and Settings → Workflow → Default Print Options) are now a three-way **Off / Auto / On** choice, and new prints default to **Auto**. "On" still forces the calibration every time; "Off" skips it entirely; "Auto" lets the printer decide. Existing queued prints and your saved workflow defaults are migrated automatically — anything that was "on" becomes "On (force)" and anything "off" stays "Off", so nothing changes for in-flight jobs until you opt into Auto. The wire encoding mirrors Bambu Studio's exactly (verified against its source), including how prints sent through a Virtual Printer inherit the slicer's own Auto/On/Off pick. ### Changed - **Orca Cloud profile sync now connects by approving a code instead of the copy-paste sign-in** — Connecting Bambuddy to Orca Cloud used to mean opening an OAuth sign-in in a new tab, watching it redirect to a `localhost` URL that fails to load, then copying that dead URL out of the address bar and pasting it back into Bambuddy. That dance existed only because Orca's auth backend (Supabase) accepts no redirect target other than `localhost`, and the deliberately-broken redirect page confused nearly everyone who reached it. OrcaSlicer has since shipped a first-class external-app pairing API (the OAuth 2.0 Device Authorization Grant, RFC 8628), so the flow is now: click **Connect**, approve a short code on your Orca Cloud settings page, and Bambuddy pairs itself — no redirect, no paste, no client secret, and it behaves identically from a LAN IP, `localhost`, or behind a reverse proxy. Bambuddy requests **read-only** access (it only lists and views your Orca Cloud profiles), keeps the pairing alive with the API's rotating refresh tokens (validated end-to-end against Orca's staging and production servers), and stores nothing beyond the issued token pair. The profile list and detail views are unchanged, so nothing downstream of the connect step looks different. The old paste-based sign-in and the email/password fallback are removed. Points at production Orca Cloud by default; `ORCA_CLOUD_API_BASE` overrides the endpoint for testing. ### Fixed - **The AMS slot popup stayed on screen and covered the filament dialog it had just opened (#2631, reporter @Jostxxl)** — Tapping **Configure** on an AMS slot left the slot's popup standing on top of the filament type/colour dialog, so the two layers overlapped: obscured content, competing backdrops, and controls of one layer sitting over the other. Most disruptive in the tablet operator workflow, where the printer is opened straight from a plate-clear scan and the next step is setting the loaded filament. **Root cause.** The slot popup is portaled to the page body at `z-[60]`, deliberately, so it can escape the stacking contexts that sibling printer cards create on the dashboard (#1336) — which also puts it *above* the Configure Slot and Link Spool dialogs at `z-50`. Nothing dismissed it: the popup is hidden only by the pointer leaving it, and a touch device never sends that event after the tap that opened it, so on a tablet it simply stayed up. On a desktop it self-cleared as soon as the mouse moved off the popup's bounds, which is why this only ever showed up on touch. **Fix.** The popup now closes itself before running any action that opens a dialog or navigates away — Configure, Assign Spool, Unassign Spool, and both Open in Inventory links, on loaded and empty slots alike — so exactly one dialog is ever on screen. Actions that report progress inside the popup (RFID re-read, Load, Unload, and Copy UUID with its confirmation tick) are deliberately unchanged, since none of them opens a dialog and closing would take their feedback with it. Covered by hover-card tests for loaded and empty slots: the popup is gone after each action, the action still fires, and it does not reappear once a pending open-timer elapses. - **Slicing a single plate failed on a filament slot the plate doesn't even use, with no way to fix it from the UI (#2628, reporter @michaelklos)** — Slicing plate 2 of a local multi-plate 3MF for an A1 failed with **"filament preset (slot 1) is not compatible with printer Bambu Lab A1 0.4 nozzle"**. Slot 1 was labelled "Filament 1 (PLA) — not used by this plate", held `SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle`, and its dropdown was greyed out — so the slice was blocked by a slot the plate never touches and the user couldn't correct. Slicing **all** plates worked. **Root cause, two independent defects.** (1) The unused-slot substitution — which replaces the profile in every slot the plate doesn't paint with, so the slicer's validators don't judge the slice on slots its G-code never touches — always copied from **slot 1**. When slot 1 is itself the unused one, that's a no-op (the reporter's log even shows it: `Substituted slot-1 filament for unused slot(s) [1]`), and with several unused slots it actively spread slot 1's foreign profile across all of them — the same poisoning #1851 removed from the picker. (2) The dialog's printer-compatibility matcher only understood BambuStudio's short `@BBL ` tag. Profiles you save yourself carry the **full** printer name instead (`… @Bambu Lab H2D 0.4 nozzle`), which the matcher classified as "can't tell" — indistinguishable from compatible — so an H2D-scoped filament was offered in the main dropdown list, auto-picked for the slot on metadata score, and handed to the slicer. **Fix.** The substitution now copies from the plate's lowest **used** slot, so an unused slot can no longer block a slice regardless of what was baked into the source file; if a plate's used slots fall outside the submitted list, the picks are left untouched rather than substituted from a slot the plate doesn't use. And the matcher now reads both tag shapes — the same two forms the AMS slot dialog has parsed since #1623 — including a trailing `(Custom)` suffix and a stray earlier `@` in the name, so a profile scoped to another printer is never auto-picked and is grouped under **Other printers** where you can still choose it deliberately. A profile whose tag names no recognisable Bambu printer stays unclassified and keeps its place in the list, exactly as before. Covered by matcher tests (long-form mismatch and self-match, display-name-vs-short-code models, the nozzle filter, `(Custom)` suffix, the A1/A1 Mini alias, unrecognisable tags, stray `@`), picker tests (the reporter's registry: the H2D profile loses to the A1 one despite a better colour and tier score, but still wins for its own printer), and substitution tests (anchors on the first used slot, doesn't poison sibling unused slots, deterministic lowest-used anchor, support slots as anchor, and the out-of-range no-op). - **A filament profile could be auto-picked for a printer it doesn't belong to when its name doesn't say which printer that is (#2628 follow-up)** — Slicing for a P2S failed with **"filament preset Bambu PLA Basic @BBL X1C 0.2 nozzle (slot 1) is not compatible with printer Bambu Lab P2S 0.4 nozzle"** — naming a profile that appeared nowhere in the slice dialog. The dialog showed `Overture PLA Matte @0.2` in every slot, including the one the plate actually uses; the slicer resolves that profile's inheritance chain and validates the X1C system profile at its root. **Root cause.** The dialog decides whether a profile fits the selected printer from the profile's own `compatible_printers` list, and falls back to reading the printer out of its NAME. This profile's name carries a nozzle size but no model, so the name fallback couldn't classify it — and the list, though present on the imported copy, is not shipped by every source: Bambu Cloud omits it from its listing on purpose (the per-profile endpoint is rate-limited), and Orca Cloud's listing carried it but Bambuddy only read the filament type and colour out of it. "Can't tell" is treated as usable, so the profile scored its way into the auto-pick for a printer it was never built for. **Fix.** Orca Cloud profiles now surface their own `compatible_printers` (it was already in the data Orca returns — no extra request), and the existing same-name bridge that lends Bambu Cloud entries their filament type and colour from another source now lends the compatible-printer list too, in both directions between the cloud sources. So whichever copy of a profile knows its printers teaches the ones that don't. As a last resort for profiles no source can classify, a bare `@` tag in the name is now read as a nozzle size: it can rule a printer out (0.2 profile, 0.4 printer) but never rules one in, since a size says nothing about the model — and a number that can't be a nozzle (`@2026`) is ignored rather than guessed at. Profiles that still can't be classified keep their place in the list exactly as before; ones that can are grouped under **Other printers**, where you can still pick them deliberately. Covered by preset-listing tests (Orca list extraction incl. the bare-string form, malformed/empty lists staying unclassified, the bridge in both directions and for both process and filament, never overwriting a list an entry already has, no-donor staying unclassified, and the borrowed list being copied rather than shared across the per-user caches) and matcher tests (0.2-vs-0.4 rejection, matching size staying unclassified, the `0.2 nozzle` / `0.2mm` spellings, numeric `0.20` vs `0.2`, implausible sizes ignored, and model-bearing tags still taking the model path). - **Switching off an accessory smart plug at the end of a print knocked the printer into "Unknown" and stalled the queue (#2629)** — With an end-of-print auto-off on a plug that powers a *filter fan* (not the printer), the printer flipped to **Unknown** the moment the plug switched off and the queue stopped dispatching to it until a manual **Force Refresh**. The printer itself never went anywhere — MQTT traffic continued a second later. **Root cause, two parts.** (1) Bambuddy treats *any* plug linked to a printer as that printer's power supply: every auto-off — time delay, temperature delay, the time-of-day schedule, a resumed-after-restart off, and a manual off from the plug card — immediately marked the printer offline, whether or not the plug feeds it. (2) That mark was **unrecoverable**. It forces `connected=False` and the state to `unknown`; `connected` heals on the very next MQTT message, but the state does not — the printer state is only rewritten when a status frame carries `gcode_state`, and the steady-state frames a P1S sends are partial. So `unknown` stuck until the next full status push, and the scheduler (which dispatches only to `IDLE`/`FINISH`/`FAILED`) treated the printer as permanently unavailable. **Fix.** The offline mark is now an explicitly *presumed* power cut: the state it overwrites is remembered, and the presumption is undone as soon as the printer sends another report on its own topic, since inbound traffic proves the power was never cut (a frame that does carry `gcode_state` still wins, and the recovery re-broadcasts so the UI un-greys). A printer whose power really was cut sends nothing, so it correctly stays offline — this also repairs the same stuck-`unknown` for a genuine printer plug whose MQTT resumes without a full push. On top of that, each plug now carries a **Powers the printer** toggle (shown when a printer is linked, in both the plug card and the add/edit dialog): leave it on for the plug that feeds the printer, turn it off for accessories — filter fan, chamber light, enclosure heater — and switching those off no longer touches the printer's state at all. Existing plugs are migrated as power plugs, so nothing changes until you say otherwise. The same flag also fixes the queue's power-on step, which used to pick whichever linked plug came first and could spend the whole power-on timeout waiting for a filter fan to boot a printer; it now picks the plug flagged as the power source. Covered by an end-to-end regression test that drives the real MQTT client, printer manager and scheduler through the reported sequence (accessory off → printer keeps talking → queue dispatches again; real power cut → stays offline), MQTT-client tests (presumed off remembers and restores the state, a partial frame recovers it, a real `gcode_state` overrides it, request-topic traffic does not count as proof of life, a reconnect discards the saved state, and a genuine second power cut is not undone), smart-plug tests (accessory plugs switch off without marking the printer offline on all four off-paths, power plugs keep the old behaviour), scheduler tests (power-plug selection), and migration tests on both SQLite and PostgreSQL. - **A2L "AMS Lite" slots showed as empty and never deducted filament** — On an A2L with the 4-slot AMS Lite attached, physically loaded slots could display as empty, filament usage was never deducted from the loaded spool, and linking an AMS-Lite slot to a Spoolman spool failed outright. **Root cause.** The A2L reports its AMS Lite as unit **id 16**, but the firmware is internally inconsistent about it: its slot-presence bitmasks sit at the bit positions for unit **6** (bit base 24), and it reports the actively-feeding slot (`tray_now`) as a **local** 0-3 index rather than a global tray id. Bambuddy's `ams_id*4+slot` convention, fed the raw id 16, probed bit positions 64-67 (always zero) and marked every loaded slot empty; the local `tray_now` was read as a global id, so usage was attributed to the wrong spool (or dropped); and the `ams_id <= 7` database constraint rejected id-16 Spoolman links. **Fix.** The AMS Lite is now normalised from unit **16 → 6** at the MQTT ingest boundary, so its global tray ids land at **24-27** — matching the firmware's own bit base, working with every existing `ams_id*4+slot` consumer (slot presence, usage tracking, deficit warnings, the scheduler, Load/Unload), colliding with nothing, and passing the database constraint. The local `tray_now` is globalised to `24+slot` so deduction hits the right spool, the valid-tray guards accept the 24-27 range, and the printer card labels the unit "AMS Lite". Prints dispatched to the Lite build the correct `ams_mapping2` (`{ams_id:16, slot_id:0-3}`) and flat mapping (local 0-3), both confirmed against the firmware's own mapping. Outbound slot commands (set filament, reset, load/unload, calibration, RFID refresh) translate the normalised id 6 back to the physical 16 on the wire, centralised in one helper. The whole normalisation is self-scoping — only unit id 16 is ever touched, so every other printer and AMS type is byte-for-byte unaffected. Verified against the reporter's live captures (slot presence via `tray_exist_bits`, and `tray_now` while printing a known physical slot); mixed setups (a regular AMS attached alongside the Lite) are out of scope and log a warning, and one uncaptured wire encoding (the physical global tray field on `load`/`calibration` commands) is extrapolated and isolated to the single translation helper. - **Bambu Cloud kept dropping to "sign-in expired" and forcing constant re-logins, even while cloud features still worked** — Since the #2562 status rework, the Bambu Cloud sign-in would flip to "expired" shortly after logging in, over and over, with nothing in the logs to explain it. **Root cause.** The rework made a genuine 401 from Bambu durably record the stored token as dead (`cloud_token_invalid_at`) — correct in principle, but it treated **any** HTTP 401 from **any** cloud or MakerWorld call as a dead token. Bambu returns 401 for plenty of non-fatal reasons (an endpoint-, region- or scope-specific refusal; a Cloudflare-edge blip; a brief backend hiccup), so a single stray 401 from any one call — including a background poll — durably signed the whole cloud integration out until the next manual re-login. Because the flag lives in the database, a setup running more than one Bambuddy instance against the same database made it worse: a stray 401 seen by either instance signed the user out in both. **Fix.** Invalidation now fires only for Bambu's documented token-expiry response — `{"code":4,"error":"Please login."}` — and never for a plain or unparseable 401, which is treated as transient (the request fails, but the session is left signed in). The same signature gate is applied to the MakerWorld path, which shares the token. A genuinely expired token is still detected and surfaced exactly as before; what stops is the false "expired" on a working session. Covered by service tests for both engines: `code:4` and the "Please login." text invalidate, while a benign `code:1`/`forbidden` 401, an unparseable 401, and (for MakerWorld) a signature-less 401 with a token do not. - **Every SpoolBuddy screen crashed the moment a text field was focused (#2616, reporters @MartinNYHC, @agentdr8)** — Tapping the Search box on the SpoolBuddy inventory, or the Search / Color Name / Brand fields on the write-tag New Spool tab, blanked the UI with a minified React error #130 ("Element type is invalid… but got: object"). It hit both internal and Spoolman inventories, so it was not data-specific. **Root cause.** The SpoolBuddy shell mounts an on-screen keyboard (`VirtualKeyboard`) that pops up on `focusin` for any text input — which is why every field on every SpoolBuddy page tripped it, while the main app (no on-screen keyboard) was fine. That component does `import Keyboard from 'react-simple-keyboard'`, a CommonJS package, and under the current bundler's CJS→ESM interop the default import resolves to the module **namespace object** (`{ KeyboardReact, default }`) rather than the component itself. Rendering that object as `` put an object where an element type belongs, and React threw. (The discrepancy is interop-specific: the test runner hands back the real component, so it only manifested in the browser build — which is why it needed a runtime, not a type, fix.) **Fix.** A small `resolveInteropDefault` helper unwraps such an interop-wrapped default: it returns the value as-is when it's already a usable element type (function/class, tag string, or a `$$typeof`-marked forwardRef/memo/lazy) and otherwise falls through to `.default` and named exports. `VirtualKeyboard` resolves the real `react-simple-keyboard` component through it, so the keyboard renders under any interop shape. Covered by unit tests for the resolver against the object shape, a named-only export, a forwardRef object, and a bare component, plus a render test that mounts the keyboard on input focus. - **The streaming overlay (`/overlay`) showed nothing in OBS when login was enabled (#2613, reporter @MartinNYHC)** — With authentication on, the overlay page worked when opened in a browser where you were already signed in, but stayed blank in OBS. The reporter suspected their Cloudflare/remote setup; it was unrelated. **Root cause.** The `/overlay/{id}` *route* renders without a login, but every piece of data it draws is auth-gated — printer status and name (`PRINTERS_READ`), one setting (`SETTINGS_READ`), and the camera stream (a camera-stream token). In your own browser those ride the JWT from local storage and the app-wide stream-token sync; OBS is a fresh browser with **no session**, so the status calls 401'd and the overlay never populated (the same would happen in any private/incognito window — remote access was never the cause). Unlike the Cam Wall (`/camwall?token=…`), the overlay had no token mode, and a long-lived token couldn't help because the JWT-gated status/settings endpoints reject it. **Fix.** The overlay is now a self-contained kiosk surface. A new **Streaming Overlay** long-lived-token scope is offered under Settings → API Keys (with a ready-made `/overlay/{id}?token=…` URL copied once on creation); the overlay page reads `?token=` from the URL and, in that mode, authenticates its status and camera calls with the token instead of a JWT (and skips the WebSocket, falling back to its existing 2 s poll). A new token-authenticated `GET /printers/{id}/overlay-status` returns exactly the fields the overlay draws — name, camera rotation, live print state, and the one setting — and nothing else. The scope is deliberately **separate from `camwall`**: the overlay names the file on screen, which the Cam Wall is trusted never to expose, so folding it in would have silently widened every existing wall token. The logged-in path (opening the overlay while signed in) is unchanged. Docs updated to explain the token and stop claiming the overlay needs no authentication. Covered by backend tests (scope boundaries in both directions — an overlay token can't reach the Cam Wall feed and a camwall/camera-stream token can't reach the overlay feed — plus the payload shape, disconnected-printer shape, and revoked/absent/garbage-token rejection) and frontend tests (kiosk mode reads the token feed and carries the token to the camera, never touches the JWT-only status endpoint or a socket; the mint UI offers the scope and hands over the assembled OBS URL). - **Reassigning a queue item while it was dispatching split it across two printers (#2615, reporter @Jostxxl)** — Editing a queue item's printer while its FTP upload was already in flight left the queue row pointing at one printer while the archive, expected-print registration, and the physical `project_file` command had gone to another. On a farm this made the reassigned-to printer look broken (marked `printing` but never sent the job), left the row permanently inconsistent, and could trigger a duplicate dispatch after a restart. **Root cause.** A queue row stays `status='pending'` for the entire (multi-minute) FTP upload — status only flips to `printing` at the very end. The edit route only blocked non-`pending` rows, so a `PATCH` during the upload window was accepted; the in-flight dispatch kept using the printer it had snapshotted at the start, while the DB row's `printer_id` changed underneath it. The existing #1853 CAS guards *cancellation* mid-dispatch, not *reassignment*. **Fix.** A `dispatching_at` claim is stamped atomically on the row (`WHERE status='pending' AND dispatching_at IS NULL`) the moment the scheduler begins dispatching, before any slow I/O, and cleared when dispatch ends. While it's held, both edit routes reject changes — the single-item `PATCH` returns **409** (re-checked immediately before the write to close the read-modify-write gap), and bulk edits skip the row — and the scheduler's selection query won't re-pick it. Startup reconciliation clears any claim orphaned by a crash mid-dispatch (no dispatch coroutine survives a restart, so every claim present at boot is stale), so a stale token can never wedge an item out of the queue. The row stays `pending` throughout, so no status-consumer, UI, completion, or reconciliation path had to change. To move a dispatching item, cancel it first (the coordinated escape) and re-queue. New column `print_queue.dispatching_at` (nullable timestamp, dialect-safe DDL — SQLite `DATETIME` / Postgres `TIMESTAMP`). Covered by scheduler tests (claim is exclusive, fails on non-pending rows, releases on every exit, skips an already-claimed row, startup clears stale claims) and API tests (reassign returns 409 with `printer_id` unchanged, bulk skips the claimed row, an unclaimed pending row still edits normally). - **A single plate printed from a multi-plate 3MF recorded the whole file's filament in statistics (#2614, reporter @Jostxxl)** — Dispatching one selected plate of a sliced multi-plate 3MF through the queue could log the **entire file's** filament against that one plate. The reporter's `heart 3.gcode.3mf` has 22 plates totalling ~12.0 kg; every completed plate recorded `12006.49 g`, so 13 runs inflated lifetime/user/project/filament stats by ~156 kg from one file. **Root cause.** The per-run value written to `PrintLogEntry.filament_used_grams` prefers the AMS-tracked spool delta, but when the tracker measured nothing (no inventory assignment on the printer) a *completed* run fell back to `PrintArchive.filament_used_grams` — which is deliberately the **sum over every plate** of the source 3MF (correct for the archive card and project rollup, #1593). The archive's `plate_id` (persisted by #2603) was never consulted on this path, so the whole-file total was copied verbatim; `cost` had the same defect, falling back to the whole-file `archive.cost`. **Forward fix.** When the archive carries a `plate_id` and its 3MF is on disk, the completed-run fallback now uses that plate's own slicer estimate (`extract_plate_metadata_from_3mf`, the same plate-scoped parse the inventory tracker uses), and scales cost by the plate's share of the whole. The tracker-measured path is unchanged (measured spool deltas still win) and single-plate archives are unaffected (plate value equals the whole-file value). **Backfill.** A startup migration repairs rows already written: for completed print-log entries whose stored grams **exactly equal** the linked archive's whole-file value (the mis-copy signature) and whose archive has a `plate_id` and an on-disk 3MF, it recomputes the plate-scoped grams + cost. The exact-match guard means tracker-measured rows (a rounded spool-delta sum) and partial-progress rows (scaled to progress) are never touched; it's idempotent (a corrected row no longer matches) and data-only, identical on SQLite and Postgres. Logs how many rows and how many grams of over-count it removed. Covered by unit tests for the forward helper (plate scoping, cost scaling, fallbacks when there's no plate_id / no file / unreadable estimate) and the backfill (mis-copy repaired, tracker/partial rows untouched, missing-3MF skipped, single-plate not relabelled, idempotent). - **Progress notification ran off the left edge of the screen in the installed iPhone PWA (#2612)** — On an iPhone 13 Pro with Bambuddy added to the Home Screen, the print-dispatch progress toast was clipped off the left side of the display — text like "prints", "plate_6", and "MB (21.0%)" bled past the edge. **Root cause.** The toast viewport is anchored `right-20` (80 px from the right, to clear the bug-report bubble) and the dispatch toast has a fixed `w-[420px]`. On a phone that's 390 CSS px wide, 420 + 80 overflows the left edge by ~110 px — the toast simply didn't fit. **Fix.** Every toast now carries a viewport-relative `max-width` (`calc(100vw - 6rem - safe-area insets)`) so it can never exceed the screen; on desktop the 420 px still wins. The viewport's position is also now safe-area-aware (`env(safe-area-inset-*)` on bottom/right) so an installed PWA clears the home indicator and a landscape notch, and the per-job filename row gets `min-w-0`/`shrink-0` so long names truncate instead of pushing the toast wide at the narrower phone width. Frontend-only; no backend, schema, or i18n change. Covered by a test pinning the viewport-relative width cap. - **Multi-plate queue prints lost the selected plate in Print History and a stopped-while-offline print stayed "printing" (#2603, reporter @Jostxxl)** — Cancelling a print queued from a specific plate of a multi-plate 3MF showed it in Print History as **Plate 1**, so you couldn't tell which plate to requeue. **Root cause.** The archive derives its plate from the *filename*, but a whole multi-plate 3MF uploads under one name with no plate suffix, so the parser defaulted to plate 1 and `extra_data` held all-plates aggregate metadata; the queue row kept the correct plate but nothing copied it onto the archive, which had no plate field at all. **Fix.** `print_archives` gains a nullable `plate_id`, copied from the queue item at dispatch (both the archive-based and library-file paths), exposed in the archive API, and rendered in Print History (falling back to no plate label only when genuinely unknown). A startup backfill copies the plate onto existing archives from their linked queue rows, so already-cancelled prints recover their plate. Additionally, **stopping a printing item while the printer was offline left the linked archive stuck at "printing"** — the queue row was cancelled but, with no printer to send an MQTT completion, nothing ever reconciled the archive. The offline-stop path now closes the archive out directly (status `cancelled`, `failure_reason` "Stopped by user (printer was offline)"); the online path is unchanged and still leaves the archive to the MQTT completion event. Column add + backfill are identical on SQLite and Postgres. Covered by tests for plate persistence, the backfill (including no-clobber/idempotency), and the offline vs online stop reconcile. - **`queue_max_concurrent_uploads` behaved as a per-batch cap instead of a refillable pool (#2602, reporter @Jostxxl)** — On a large farm, unused upload slots sat idle whenever any upload from the current batch was still running. **Root cause.** `check_queue()` awaited `_dispatch_selected()`, which awaited `asyncio.gather()` over the whole selected batch before returning — so the scheduler's run loop was blocked until the *slowest* FTP transfer in the batch finished. A 96 MB 3MF that took 513 s to upload left 15 of 16 configured slots unused for 8.5 minutes on a 93-printer farm, even as other printers came free; jobs that became eligible during the long upload couldn't be dispatched. The batch-await was load-bearing for one reason: `_start_print` flips a row `pending → printing` only *after* its upload completes, so returning early would have let the next pass re-dispatch the still-`pending` in-flight rows. **Fix.** Uploads now run as independent background tasks tracked in a `_inflight` pool. Each tick excludes in-flight item rows (and their printers) from selection, launches at most `limit − len(_inflight)` new uploads, and returns immediately — so a freed slot refills on the next fast (3 s) tick instead of waiting out the whole batch, and the configured limit finally behaves as a continuously-refillable worker pool. The no-double-dispatch invariant the batch-await used to provide is now carried by the in-flight exclusion; the `pending → printing` CAS, the busy-printer guard (#2598), the per-printer dispatch hold, auto-drying exclusion (in-flight printers stay out, including on the no-pending-items path), and per-item failure isolation are all preserved and run per task. Investigated with the reporter's large-farm hotfix and reproduction; covered by rewritten pool tests (cap holds across refills, freed slot refills, in-flight item/printer excluded from re-selection, check_queue returns without awaiting uploads). - **Configuring a built-in/generic filament on an AMS slot reverted to the old profile a moment later (#2604, reporter @Jostxxl)** — Selecting a built-in preset (e.g. Generic ABS) through **Printer → AMS slot → Configure** briefly showed the new material on the printer, then the slot snapped back to whatever was there before (e.g. an old Generic PETG). **Root cause.** The Configure AMS Slot modal sends built-in, local, and Orca-generic presets with a `GF*` `tray_info_idx` but an **empty** `setting_id` (those presets carry no Bambu Cloud setting id of their own), and the `configure_ams_slot` route forwarded that empty value straight to `ams_filament_setting`. The firmware treats a slot that has a filament id but no setting id as half-configured: it accepts the update, then reverts to its previously stored profile. The inventory/assignment path already guards against this by deriving the setting id from the filament id, but the manual Configure path didn't, leaving two inconsistent code paths. **Fix.** `configure_ams_slot` now back-fills `setting_id` from the resolved `tray_info_idx` via `filament_id_to_setting_id` whenever the client sent none (e.g. `GFB99` → `GFSB99`), mirroring the inventory path. Doing it server-side also protects API callers and any future frontend. `P*` user presets and already-`GFS*` values are left untouched, and an explicitly-supplied `setting_id` (including the `PFUS*` pair) still passes through unchanged. Covered by tests for the built-in empty-`setting_id` case and the material-only generic-fallback case both publishing a derived `GFS*` id. - **The HT-A (AMS-HT) spool vanished a few seconds after every power-on (#2594, reporter @GuillaumeHouba)** — On an H2C, the spool in the HT-A high-temp AMS on the left nozzle showed correctly with its RFID assignment on power-on, then disappeared seconds later; the regular AMS spools stayed. **Root cause.** The AMS merge in `_handle_ams_data` clears a tray when it receives a partial `{id, state}` update whose `state != 11` — the rule that lets 4-slot AMS units (e.g. H2D) report an emptied slot with just `{id, state}` and no `tray_type` (#784), where `11` = loaded. But an **AMS-HT** (single-tray high-temp dry box, unit id ≥ 128) reports its *loaded* tray as `state=9`, not 11 — it doesn't feed filament into a shared buffer the way a 4-slot AMS does. So the partial `{id:0, state:9}` the printer sends for the HT tray on power-on was misread as "slot emptied," and Bambuddy wiped the tray's `tray_type` / RFID / Spoolman assignment. The support log showed it plainly: every "state=9 (not loaded) — clearing stale tray data" was on AMS 128, never on the regular AMS unit 0 (which correctly reports 11). **Fix.** The `state != 11 → empty` heuristic is now skipped for AMS-HT units (id ≥ 128); their differing single-tray state semantics mean a partial state update must not clear a present spool. A genuine HT spool removal still clears through the explicit `tray_type == ""` update and the `tray_exist_bits` cleanup, both unchanged, and regular AMS behavior (id < 128) is untouched. Covered by tests for the HT tray surviving a `state=9` partial, the HT still clearing on an explicit empty, and the existing regular-AMS `state=9`/`10`/`11` cases. - **A start-print dispatched to an already-busy printer could cancel the running job (#2598, reporter @khaosdoctor)** — On an A1 mini across a night of prints, jobs were cancelled with no apparent cause; debug logs showed Bambuddy sending `project_file` twice ~3 minutes apart with no completion between, and the printer answering `0500_4004` ("Device is busy and cannot start a new task") — which on that model cancels the RUNNING print. **Root cause.** `start_print()` in the MQTT client guarded only on connection state (`self._client and self.state.connected`) — it published `project_file` with no check on the printer's `gcode_state`. The scheduler *does* gate dispatch on an idle check, but that check treats `FINISH` as idle, and a printer can keep reporting `FINISH` for tens of seconds *after* it accepted a `project_file`; combined with a dispatch watchdog that reverts a queue item and releases its dispatch hold when it doesn't observe the active-state transition in time (#2555), a re-selected item could reach the FTP upload while the printer had actually started, so the start command landed on a live print. **Fix (defense-in-depth).** (a) `start_print()` now refuses to publish `project_file` when the printer is in an active state (`PREPARE` / `SLICING` / `RUNNING` / `PAUSE`) and returns without sending — a single guard at the one publish choke point that every dispatch path (queue, manual, webhook, Virtual-Printer forward) funnels through. `IDLE` / `FINISH` / `FAILED` remain valid start targets. (b) The scheduler re-checks the live printer state right before the FTP upload and *defers* a busy printer (leaves the item pending for a later tick) instead of uploading and dispatching — no wasted transfer, no collision. (c) If the printer goes busy during the upload window and the start command is refused, the scheduler reverts the item to pending (a deferral) rather than marking it failed — a busy printer is not a failure. Covered by tests for the client-level guard (refused while busy, published while idle, guard precedes the connection check) and the scheduler deferring both before the upload and after a busy-refused start. Note: a transport-level MQTT QoS-1 replay on reconnect would bypass the client guard, but the dispatch/watchdog reconnect path already hard-resets the client with a fresh session so it has no inflight `project_file` to replay. - **Three more idle-in-transaction / thundering-herd paths surfaced by continued farm testing (#2572, reporter @Jostxxl)** — On `origin/dev` with 93 printers and multiple concurrent UI clients the reporter timestamp-correlated the surviving pool pressure to three remaining paths, none of them auth-related. **(a) The scheduler held its per-item session across preheat and the FTP upload.** `_dispatch_selected` opens one `async_session` per queue item and hands it to `_start_print`, which reads the printer/archive rows up front and then runs the preheat/heat-soak wait and the FTP delete+upload — all on the transaction opened by those first `SELECT`s. One correlated session's last statement was a `settings` `SELECT` at the exact moment the log showed "Starting queue item" → preheat → "FTP upload started" for a 96 MB 3MF; the transaction stayed open for the whole transfer. (This refines the earlier note that the scheduler paths were "already bounded" — the per-item session itself was the hold.) `_start_print` now commits right before the FTP delete/upload, and `_preheat_and_soak` commits after its read phase and before the up-to-15-minute soak wait (both loops touch only `printer_manager` state and `asyncio.sleep`, no DB). `expire_on_commit=False` keeps the loaded rows readable; the status writes afterward (upload-failure path and the pending→printing CAS) transparently open a fresh transaction. **(b) `/cloud/filament-info` held its request session across sequential Bambu Cloud round-trips and single-flighted nothing.** The route took its session via `Depends(get_db)` (held for the whole request), read the stored token, then looped over the uncached ids issuing one external `get_setting_detail` HTTP call each — so the session sat idle-in-transaction across N cloud calls, and because the printer overview mounts one filament-info request per printer card, several browsers hit the same uncached preset at once and each issued its own cloud call. The route now releases the transaction (`rollback`) right after the token read and before the cloud loop (Phase 3's local-preset read reopens a fresh one), and concurrent misses for the same id single-flight through one shared cloud call. **(c) `/printers/{id}/cover` had no in-flight coalescing.** The connection was already released before the download, but simultaneous clients could all miss the cache and each run the full multi-path FTP lookup + 3MF extraction (one observed transfer pulled an 81 MB 3MF while real print uploads were in flight). Identical concurrent cover requests now coalesce: the first becomes the leader and the rest await it, then serve from the positive/negative cache it filled. Also adds `pool_use_lifo` (PostgreSQL default on, `DB_POOL_USE_LIFO` override, shown in `/system/db-pool`) so a bursty farm keeps a small hot connection set busy and lets excess overflow connections age out via `pool_recycle` instead of churning the whole pool. Covered by tests: the scheduler releasing its connection before both the FTP upload and the soak wait, the filament-info single-flight (concurrent misses share one cloud call, cache-hit skips cloud, a failed fetch leaves no stuck in-flight entry), and concurrent cover requests downloading once. - **An "Any [model]" queue job dispatched from a Virtual Printer printed to the empty external spool and aborted at layer 0 (#2595, diagnosed by @Sawtaytoes, PR #2596)** — On a farm of identical X1Cs with different filaments loaded per AMS, the intended flow — VP in Queue mode, auto-dispatch, target **Any X1C**, force-colour-match picks the printer that has the right spool — sent the job to the correctly-matched printer and then failed: the printer ignored the AMS, pulled the empty external spool, and aborted with "not enough filament", even though the mapped slot was loaded (the same print via a specific printer, or straight from the slicer, worked). **Root cause.** A slicer talking to a Virtual Printer only ever sees the VP's external spool — a VP advertises no AMS — so the slicer sends `use_ams=false`, and VP intake stamps that onto the queue item. But an "Any [model]" item is colour-matched to a real printer *at dispatch*, resolving a real AMS slot in `ams_mapping`; the scheduler still forwarded the stale `use_ams=false`. The print-command builder only ever forced `use_ams` **off** (the all-external case) and never back **on**, so `use_ams=false` shipped alongside `ams_mapping=[]` → external spool → abort. **Fix.** For single-nozzle printers the resolved mapping is now authoritative: a real AMS tray (0-253) forces `use_ams=true`; an explicit external selection (254/255) still forces it false; an unresolved `-1` mapping does neither (preserving the #2589 contract — it should have been recomputed upstream, and must not be silently promoted to AMS or downgraded to external). Dual-nozzle printers are untouched, where `use_ams` encodes nozzle routing rather than an on/off flag. Because the correction lives at the single command-builder choke point, it fixes the VP, queue, and manual paths alike. Covered by tests for the VP `false`+real-tray promotion, padded mappings, all-external staying off, unresolved `-1` staying put, the original all-external downgrade, and the dual-nozzle bypass. - **Reconnecting or restarting inflated Stats → Total Print Time by hundreds of hours on large farms (#2592, reporter @Jostxxl)** — On the reporter's farm a restart pushed Total Print Time from ~1,500h to 3,215h. When a printer reconnects, the connected edge runs `reconcile_stale_active_prints`, which closes out every archive still stuck in `status="printing"` (missed completions, disconnects, restarts) by synthesising an aborted `on_print_complete`. That wrote a `PrintLogEntry` whose duration was `completed_at - started_at` — but for a reconciled archive the real end time is unknown: the print stopped somewhere during the disconnect, and `completed_at` is only the reconnect moment. So each stale archive banked its entire multi-day gap as print time (one row was 51.9h), and a printer with several stale archives contributed hundreds of fabricated hours at once. Worse, the Stats total *recomputed* `completed_at - started_at` whenever the stored duration was falsy, so storing NULL wouldn't have helped. Reconciled completions now log an explicit `duration_seconds = 0` (honest "no measured runtime") and the two Stats time paths trust a stored 0 instead of recomputing from the stale timestamps — legacy rows that never recorded a duration still fall back as before. Reconciled aborts also get a truthful `failure_reason` ("Stale - reconciled after reconnect, end time unknown") instead of being mislabelled "User cancelled". Genuine long prints are untouched: nothing is capped, a still-running >24h print is never treated as stale, and a real >24h run keeps its full measured duration. Re-running reconciliation is already idempotent (the archive flips to `aborted`, so it isn't re-selected). Existing inflated rows from before this fix are not auto-corrected — they're indistinguishable from real cancellations in the database, and a blanket cap would clobber genuine long prints; the reporter repaired his own rows by hand. Covered by tests for the multi-day reconcile, multiple stale archives per printer, a retained >24h print, and the Stats total ignoring reconciled time while still counting real runtime. - **H2C prints intermittently recorded no filament and never deducted from inventory (#2582, reporter @gyrene2083)** — On an H2C (firmware `01.02.00.00`) filament usage sometimes wasn't deducted and the Print Log showed no filament for that print; the reporter confirmed the tell-tale detail — the failed print's archived `.3mf` didn't exist to download. Filament totals, the Print Log filament column, and the weight deduction all read the sliced 3MF's data, so when that file can't be pulled off the printer the print drops to the no-3MF fallback archive and every one of them comes up empty. The download itself was the failure: the H2C is the same H2 generation and the same firmware line as the P2S, whose FTPS data channel trips a vsFTPd + TLS 1.3 session-reuse bug on Python 3.13 (#1401) — and the X2D hit the sibling handshake variant (#1638). Both were fixed by capping that model's FTP control/data channel to TLS 1.2 via the per-model FTP profile registry, but the H2C had no entry and so ran on the Python-default TLS 1.3, leaving its 3MF downloads to fail the same way (intermittently, matching the "sometimes works, sometimes doesn't" report — the session-reuse race rather than a hard handshake failure). The H2C now gets the same `cap_tls_v1_2` profile as the P2S/X2D (with its `O1C`/`O1C2` SSDP codes mapped to it), so the sliced 3MF comes off the printer reliably and the slice data — filament total, Print Log filament, and the inventory deduction — is populated again. H2D is deliberately left on the default profile; it negotiates TLS 1.3 without this fault. - **An unresolved AMS mapping silently dispatched a P1S print to the empty external spool (#2589, reporter @Jostxxl)** — A queued P1S job with a regular AMS attached, two compatible PETG spools loaded, and nothing on the external spool holder started against the *external* feed and paused seconds later with a filament-runout HMS. The queue row was correct on its face — `use_ams=true` — but carried `ams_mapping=[-1]`, and Bambuddy turned that into a print with no AMS. Two faults combined. **A `-1` was read as "external spool."** The command builder's rule for "all slots are external, so drop `use_ams`" tested `t < 0 or t >= 254` — folding the *unresolved* sentinel (`-1`) in with a genuine external selection (`254`/`255`). An explicit external print serializes as `[254]`; an unresolved slot serializes as `[-1]`, and the two mean opposite things — one is "use the spool holder", the other is "we never worked out which tray." Only `>= 254` may now force `use_ams=False`; `-1` never does. **The unresolved mapping was trusted instead of recomputed.** The scheduler only computes a mapping when the row has *none*; a stored `[-1]` is non-empty, so it looked "already resolved" and was passed through verbatim — even though the backend had the live AMS trays and the plate's filament requirements right there and could have matched them. Dispatch now recomputes whenever the stored mapping is entirely unresolved, so a bogus `[-1]` self-heals against the trays actually loaded (and any pre-existing stuck row heals on the next scheduler pass); if nothing compatible is loaded it is cleared rather than sent, so the firmware reports a clear mapping error instead of quietly printing to an empty feed. **Where the `[-1]` came from.** The Print dialog builds the mapping from the selected printer's live status; if you submitted a single-printer job in the instant before that status query resolved, it matched against zero known trays and serialized every required slot as `-1`. The dialog now waits for the printer's AMS status before it will submit (showing a brief "Waiting for AMS status from …" notice), and the mapping hook returns *no* mapping rather than an all-`-1` one while the trays are unknown — so the scheduler resolves it at dispatch. A genuine no-match with trays present still serializes `-1` and surfaces the mismatch as before. **Tests.** Backend: the command builder keeps `use_ams=true` for `[-1]`/`[-1,-1]` and a padded `[-1,-1,5]`, still drops it for an explicit `[254]`; the scheduler recomputes a stored `[-1]`, leaves a resolved (or manually-overridden) mapping untouched, and clears an unresolvable one. An existing test that asserted the old `[-1] → use_ams=False` behaviour was corrected to the fixed contract. Frontend: the mapping hook returns `undefined` while status is loading, resolves to the AMS tray once it arrives (type-only match with strict colour off), and still emits `-1` for a real mismatch. Full backend suite and the PrintModal/mapping frontend suites green. - **Pushover Emergency priority (2) was rejected by the Pushover API (#2586)** — Setting a Pushover provider to priority 2 (Emergency) made every notification fail with Pushover's own error that `retry` and `expire` are required. Pushover *mandates* those two parameters for Emergency alerts — `retry` is how often it re-alerts (minimum 30 s) and `expire` is when it stops (maximum 10800 s / 3 h) — and Bambuddy never sent them, so the message was refused before it left the app. Priority 2 now works: two new optional fields (Emergency Retry / Expire) appear on the Pushover provider **only when priority is set to 2**, default to a sensible 60 s / 3600 s, are clamped to Pushover's legal 30–10800 s range, and are sent only at priority 2 (Pushover ignores them at other priorities). Emergency alerts now keep re-alerting until acknowledged, as intended. - **P2S RTSP timeout could leave the fan-out camera stream permanently stalled (#2580, reported and diagnosed by @ronaldheft, fix shape from PR #2581)** — After an RTSP read timeout, the stream cleanup killed the stalled ffmpeg and then waited *unbounded* for it to be reaped. A SIGKILLed ffmpeg stuck in uninterruptible I/O on a dead RTSP socket can take arbitrarily long to exit, so the fan-out stream coroutine sat parked in that wait — in the reported case for 12 hours — while every new viewer attached to the stalled broadcaster and got no frames (snapshots and diagnostics kept working, since those open fresh connections). The post-kill wait is now bounded (2 s): on timeout the stream abandons the zombie — the orphan janitor's /proc scan reaps it on its next pass — and proceeds to its normal reconnect, so live view recovers by itself. The same unbounded wait hid in two more places, both bounded too: the camera *Stop* endpoint (which would hang the very request a user makes to recover a stuck stream) and the periodic orphan-cleanup janitor itself (which is the safety net that recovers stalled streams, and so can least afford to block). - **Queue edit showed the sliced-for model as the scheduler target, and a cross-model queue row could dispatch G-code to an incompatible printer (#2578, reporter @Jostxxl)** — Two bugs with one root. The "Any \" assignment button labeled itself from the file's slice metadata while the scheduler actually used the row's `target_model`, so an X1C-sliced item targeting H2D read "Any X1C" above "Scheduler will assign to first available idle H2D printer". Worse, the mismatch could be *created* silently: the sliced-for model loads asynchronously, and clicking "Any Model" before it arrived pre-selected the first model alphabetically — on a mixed X1C/P1S/H2D farm that's H2D — after which the model dropdown hid itself, leaving no way to see or fix the wrong target. Nothing downstream checked compatibility, so the scheduler would happily hand X1C G-code to an H2D. Now: the target model is never silently defaulted (the dropdown stays visible in model mode, pre-selected to the sliced-for model when available, and back-fills once the metadata loads); the button reflects the actual target; a warning shows when the target differs from the sliced-for model. Compatibility is enforced end-to-end with an explicit G-code interchange family table (X1/X1C/X1E/P1P/P1S interchange; everything else exact-match — files without slice metadata are never blocked): incompatible models are disabled in the dropdown, queue create/update reject a mismatch with a clear 400 (so API-created rows can't sneak in), and the scheduler holds back pre-existing mismatched rows with an actionable waiting reason instead of dispatching them — fix the target via edit and the job flows again. - **Manual jog could drive an axis past its travel limit into a collision (#2579, reporter @R3play210)** — Jog the bed up from Bambuddy and, instead of stopping at the travel limit, it keeps going until the nozzle hits the plate; X/Y overrun too, on every model. Instrumenting the exact bytes sent to an H2D showed Bambuddy issuing a clean, correct move at the limit — `G91` / `G1 Z-1.00 F600` / `G90`, no endstop manipulation — that the printer executed straight past the stop, while the machine's **own touchscreen refuses the identical motion**. **This is a Bambu firmware bug: the firmware does not enforce its soft endstops on G-code received over MQTT** (the path every remote tool, Bambuddy included, must use), and it reports no axis position, so Bambuddy cannot know where the bed is to stop it either. It is not fixable from our side. Two things change here: (1) the jog no longer wraps moves in `M211 S0`/`S1` — the old code disabled the firmware's soft endstops *globally* around every jog, which also broke the **touchscreen's** limits until the printer was power-cycled; it now sends a bare move and never touches `M211`, so the touchscreen stays protected. (2) The jog panel now shows a prominent warning that travel limits are **not** enforced during manual moves because of this firmware bug, so nobody trusts the control to stop at the limit. Client-side travel-limit enforcement (dead-reckoning from a home) is tracked separately as the only real mitigation. If your printer currently overruns even from its touchscreen, power-cycle it once to restore the endstops an older Bambuddy build disabled. - **External spool kept its old inventory filament after the type was changed on the printer (#2575, reporter @ajbastien)** — Assigning a new filament to the external spool (e.g. generic ABS in place of generic TPU) left the previous inventory spool assigned, so an ABS spool stayed mapped to TPU. The reconciliation that unlinks a stale external-spool assignment lives in `on_ams_change`, but that callback only fired on changes to the regular AMS units — its change-hash never included the external spool (`vt_tray`/`vir_slot`), and the external-spool data is stored after the AMS handler runs. External-spool identity changes (type, colour, tag, or a reset to empty) now re-trigger the callback so the stale assignment is unlinked; the fill-percentage (`remain`) is deliberately excluded from the fingerprint so a running print doesn't fire it on every push. Follow-up: the auto-unlink now also broadcasts `spool_assignment_changed` for each cleared slot — previously only the manual assign/unassign endpoints did, so an open browser kept rendering the now-unlinked spool on the slot until an unrelated refetch, which read as "the fix didn't work" even though the server state was already correct (reporter confirmed a browser refresh showed the right state all along). - **Two or three users opening the UI at once exhausted the PostgreSQL connection pool immediately (#2572, reporter @Jostxxl)** — Even after the session-hygiene fixes below, the reporter's 93-printer farm saturated the pool the moment a couple of clients logged in together: the log filled with `QueuePool limit of size 10 overflow 20 reached, connection timed out` from `permission_checker` / `is_jti_revoked` / `is_auth_enabled`, and every one of the 30 stuck sessions was `idle in transaction` with the same last statement — the `auth_enabled` settings `SELECT`. Three things had regressed on `dev` after an earlier configurable-pool change was reverted and never re-landed (only the route-by-route session fixes were). **(a) The pool was back to a hard-coded, farm-hostile size.** PostgreSQL ran on `pool_size=10 + max_overflow=20` (30 connections total) with no way to raise it; the `DB_POOL_SIZE` / `DB_MAX_OVERFLOW` / `DB_POOL_TIMEOUT` / `DB_POOL_RECYCLE` env knobs and the `GET /api/v1/system/db-pool` gauge were gone. The PostgreSQL default is again `20 + 80` (100) with `pool_pre_ping` and a 1800s `pool_recycle`, all env-overridable, and `/system/db-pool` is back (it reports resolved config + live checked-out/checked-in/overflow without itself checking out a connection, so it stays truthful under saturation). SQLite is unchanged at `20 + 200`. **(b) Every protected request re-queried `auth_enabled` from the database.** That per-request round-trip — the exact `SELECT` seen on all 30 stuck sessions — is back to being cached for 30s. The cache is deliberately one-directional: only an *enabled* result is ever cached, so a stale read can only make a request *require* auth that a moment ago didn't — it can never skip a check that is now required (staleness fails closed). Toggling auth invalidates it immediately; the TTL is only a backstop for out-of-band changes. **(c) Every authenticated request checked out two pooled connections, not one.** The permission dependencies already hold a session, but the revoked-`jti` check opened a *second* `async_session` on top of it — so a burst of concurrent logins (the SPA fires many protected endpoints at once) needed twice the connections it should. `is_jti_revoked` now accepts and reuses the caller's session; the two token dependencies that check the jti before they have a session open were restructured to open one first, so each authenticated request makes a single checkout. Covered by tests for the dialect-aware pool sizing + env overrides, the pool-status shape, the True-only fail-closed cache (enabled cached, disabled/unconfigured never cached, DB error propagates), and the jti check reusing a provided session versus opening its own. - **The file-manager, storage, camera-snapshot and timelapse routes still held a DB connection across their FTP/camera work (#2572, reporter @Jostxxl)** — After the earlier #2572 fixes the farm still bled connections over a long run — the pool crept from its normal ~14 to the full 300 across ~23 hours (with only ~20 of 93 printers powered on) and then threw `QueuePool limit … connection timed out`. These were the remaining routes of the same class: each took its printer row via `Depends(get_db)`, whose session stays open for the whole request, and then talked FTP to the printer — a listing, a multi-MB download, a delete, a storage probe — with a browser polling the cover/snapshot tiles for every card, offline ones included, and 73 unreachable printers each burning a full FTP timeout. The printer-files endpoints (`/files`, `/files/download`, `/files/gcode`, `/files/plates`, `/files/plate-thumbnail`, `/files/download-zip`, `DELETE /files`, `/storage`), the camera **snapshot** endpoint (sibling of the already-fixed stream), and the timelapse **scan** and **select** endpoints now read what they need in a short session, release the connection *before* the FTP/camera work (`expire_on_commit=False` keeps the loaded `printer.*` columns readable), and — for timelapse, which also writes — re-open a fresh short session only to attach the downloaded file. Behaviour is unchanged; the timelapse-scan boundary is pinned by a regression test that mocks the FTP listing/download and asserts both the detached-row reads and that the attach persists through the fresh session. Completes the route-by-route half of the #2572 effort (camera stream, cover, on_print_start, timelapse scan, finish photo, notification snapshots). - **Four async FTP helpers had no overall timeout, so a saturated FTP thread-pool could pin a caller — and any DB connection it held — indefinitely (#2572, reporter @Jostxxl)** — FTP runs in a fixed 48-worker thread pool. `download_file_try_paths_async`, `download_file_bytes_async`, `get_storage_info_async` and `delete_file_async` wrapped their worker in a bare `run_in_executor` with no `asyncio.wait_for` (unlike `list_files_async`/`download_file_async`, which already had one). The per-socket timeout only bounds a worker once it *starts*; it does nothing for the time a call spends **queued** waiting for a free worker. On a farm where offline printers keep every worker parked on dead connects, that queue wait is unbounded — so an awaiting coroutine, and any pooled DB connection it was still holding, could wait forever. All four now cap the whole operation with `asyncio.wait_for` (returning the same failure sentinel on expiry, the orphaned worker's result discarded), so a backed-up FTP pool can no longer pin a caller — defence-in-depth beneath the route fixes above. - **A wedged SMTP server could freeze the entire event loop during an email notification (#2572, reporter @Jostxxl)** — `_send_email` ran `smtplib` **synchronously on the event loop** and constructed the connection with **no timeout** (smtplib then falls back to the global socket timeout, which the app never sets). A relay that accepts the TCP connection but stalls on the greeting/login/DATA left the send blocked forever — and because it ran inline, it stalled every other coroutine with it. The send now runs off the loop (`asyncio.to_thread`) with an explicit 30s connect timeout, and `quit()` moved into a `finally` so a mid-send error can't leak the socket. Latent bug surfaced while auditing #2572; it presents as a stall/latency spike rather than the pool leak, but the same "blocking I/O on the loop" family. - **The API didn't start serving for ~100 seconds on a large farm while it connected to printers one at a time (#2572, reporter @Jostxxl)** — On the reporter's 93-printer farm port 8000 didn't respond until roughly 100 seconds after the service started. The cause was in the FastAPI lifespan: `init_printer_connections` looped over every active printer and `await`ed each connection *serially*, and each `connect_printer` ends in a fixed one-second settle wait. The MQTT connect itself is non-blocking — `BambuMQTTClient.connect()` only calls `connect_async()` + `loop_start()`, so the handshake runs on a background thread — which means that one-second wait, times the fleet size, was pure serial dead air that the lifespan blocked on *before* the ASGI server began accepting requests. The connections are now started concurrently with `asyncio.gather`, so the whole step takes about a second regardless of how many printers you run, and the dashboard is reachable almost immediately. Each connection's result is also isolated (`return_exceptions=True`): a single unreachable printer no longer aborts the rest — or, as the old un-guarded serial `await` allowed, the entire startup. The MQTT clients still connect in the background exactly as before; only the startup wait is parallelized. - **The print-start handler held a DB connection open across plate detection and the 3MF download (#2572, reporter @Jostxxl)** — After farm-testing the first round of #2572 fixes the reporter still saw `idle in transaction` sessions lasting minutes, and traced one to `on_print_start`: its last statement was `SELECT print_archives…`, immediately followed in the log by the printer's own `on_print_start` → `Trying filenames` → FTP work. The handler opened a single database session at the top and held it to the very end of the function — across two slow I/O blocks that need no database: the optional plate-detection camera capture (a 2.5s chamber-light settle plus an FTP/RTSP grab) and, on the new-archive path, the 3MF FTP download itself (up to five remote paths per candidate filename, each with retry/backoff — the code's own comments cite worst cases of tens of minutes under FTP contention). So one pooled connection sat idle-in-transaction for the whole of both, once per starting print, and print starts cluster on a farm. The connection is now released at both boundaries: reaching either point, only read `SELECT`s have run on that path (every write branch returns earlier), so a commit persists nothing and simply ends the read transaction, returning the connection to the pool for the duration of the I/O; the next query re-acquires a fresh one, and `expire_on_commit=False` keeps the already-loaded `printer.*` columns readable with no lazy load. Behaviour is unchanged. Continues the #2572 effort (camera stream, timelapse scan, finish photo, notification snapshots) to stop holding sessions across slow I/O. - **The printer-cover endpoint held a DB connection open across the FTP thumbnail download (#2572, reporter @Jostxxl)** — The reporter's second correlation: a transaction whose last statement was `SELECT printers…`, matched in the log to the cover route (`Cover: resolved plate …` / `Trying to download cover … (trying 4 paths)`), still open more than three and a half minutes later. `GET /printers/{id}/cover` took its printer row via `Depends(get_db)`, and `get_db` is a `yield` dependency — its session stays open for the whole request, including the cover's 3MF download (up to eight remote paths × retries with backoff, minutes under the same single-FTP-socket contention that produces the 425s). The session was used for exactly one `SELECT`; everything after reads already-loaded `printer.*` scalars, `printer_manager`, and FTP/zip — no database. The endpoint now fetches the printer in a short-lived session and releases the connection *before* the download (`expire_on_commit=False` keeps the columns readable), mirroring the camera-stream fix. Pinned by a regression test that fails if a `get_db`-held session is ever re-added to the route. - **Queue polling re-parsed every 3MF from scratch on each poll (#2573, reporter @Jostxxl)** — The Queue page polls `GET /api/v1/queue/` every few seconds, and for each item with a `plate_id` the serializer called three separate helpers — `extract_print_time_from_3mf`, `extract_filament_usage_from_3mf`, `extract_bed_type_from_3mf` — each of which independently opened the item's ZIP and re-parsed `Metadata/slice_info.config`. With 22 queued items that is 66 ZIP-open + XML-parse operations per poll, run in the event-loop thread, repeated for *every* connected browser even though the files never changed. The three values now come from a single combined parse (`extract_plate_metadata_from_3mf`) cached by file revision — the key is `(path, plate_id, mtime_ns, size)`, so an unchanged file is parsed at most once and a replaced or edited file transparently re-parses with no manual invalidation. The three legacy helpers still exist (other callers use them) but now delegate to the same cached parse, so usage-tracking and Spoolman paths benefit too; the queue hot path calls the combined helper once per row. The cache is a bounded (512-entry) LRU guarded by a lock so it stays small and is safe from worker threads. Listing an unchanged queue now serializes DB data and does no repeat 3MF parsing. (The reporter's broader farm-scale asks — a WebSocket-delta queue, an initial snapshot endpoint, ETag/304 support, per-row plate-request batching — are a separate queue-page redesign, not part of this fix.) - **Progress-milestone and HMS-error notifications held a DB connection across the camera snapshot (#2572, reporter @Jostxxl)** — Both notification paths inside `on_printer_status_change` (the 25/50/75% milestone push and the new-HMS-error push) opened a database session, then captured a camera snapshot for the notification image — an up-to-15s RTSP grab — and sent the notification, all with the session held. So a pooled connection sat idle for the whole grab, per milestone/error, per printer; on a farm those fire constantly. The snapshot needs no database, so it now runs between two short sessions: one to read the printer name, then the grab with no connection held, then a fresh session for the notification send. Behaviour is unchanged; pinned by a test that fails if the snapshot ever runs while a session is open. The AMS-change notification path was left as-is for now (it holds a per-printer lock across its write and needs separate care). Continues the #2572 effort (camera stream, timelapse scan, finish photo). - **Finish-photo capture held a DB connection open across the whole camera grab (#2572, reporter @Jostxxl)** — When a print finishes, the background finish-photo task reads a couple of rows (the capture setting, the printer, the archive) and then runs a capture pipeline that can take tens of seconds — timelapse last-frame extraction, waiting up to 20s for the stage-22 producer, an external-camera HTTP grab, or a fresh RTSP shot. It held one database session open across that entire pipeline, so a pooled connection sat `idle in transaction` for the full capture, once per finishing print — and finishes cluster on a farm. It now reads what it needs in a short session, releases the connection, runs the capture with no session held, and re-opens a fresh short session only to append the photo to the archive. Behaviour is unchanged. Continues the #2572 effort (camera stream, timelapse scan) to stop holding sessions across slow I/O. - **Timelapse scan held a DB connection open across every FTP round-trip (#2572, reporter @Jostxxl)** — After a print completes, `_scan_for_timelapse_with_retries` polls the printer's FTP server for the new timelapse file (up to 4 retry attempts, plus a name-match fallback). Each attempt opened one database session and held it across the FTP directory listing *and* the multi-MB video download — so a pooled connection sat `idle in transaction` for the whole transfer, once per attempt, per completed print. When several prints finish together on a farm that adds up. The scan now reads the archive + printer in a short session, releases the connection, does the FTP list/download with no session held, and re-opens a fresh short session only to attach the downloaded file. Behaviour is unchanged; the existing scan tests already exercise the read→download→attach path. Continues the #2572 effort (after the camera-stream fix) to stop holding sessions across slow I/O; the scheduler paths were reviewed and found already bounded (single loop + capped concurrent uploads, with an explicit pre-dispatch commit) so they were left as-is. - **Live camera stream held a database connection open for its entire duration (#2572, reporter @Jostxxl)** — The `/camera/stream` MJPEG endpoint took its printer row via `Depends(get_db)`, but `get_db` is a `yield` dependency: its session isn't released until the response body finishes streaming, which for a live stream is however long the browser tab stays open — minutes to hours. On a large farm every open camera tile therefore pinned one pooled DB connection `idle in transaction`, so a wall of dashboards could drain the pool on its own (a top contributor to the exhaustion in #2572). The endpoint now fetches the printer in a short-lived session and releases the connection *before* it starts streaming (`expire_on_commit=False` keeps the already-loaded columns readable). Pinned by a regression test that fails if a `get_db`-held session is ever re-added to the route. Part of the broader effort to stop holding sessions across slow MQTT/FTP/camera/3MF work. - **PostgreSQL connection-pool exhaustion on large printer farms (#2572, reporter @Jostxxl)** — On a ~93-printer farm the SQLAlchemy pool (hard-coded `pool_size=10` + `max_overflow=20` = 30 connections) was repeatedly saturated with all connections `idle in transaction`; unrelated API requests then waited out the 30-second pool timeout or failed in the auth middleware, and an unauthenticated `/api/v1/printers` probe took ~25s to return 401. Three things fed the pressure: the pool was fixed and not configurable; every authenticated request re-queried `auth_enabled` from the DB (the middleware alone opened a session per request just to probe it); and the pool was small for a farm. This change (a) makes pool sizing configurable via `DB_POOL_SIZE` / `DB_MAX_OVERFLOW` / `DB_POOL_TIMEOUT` / `DB_POOL_RECYCLE` env vars and raises the PostgreSQL default to `20` + `80` (100 total) with `pool_pre_ping` and a 1800s `pool_recycle`; (b) caches the `auth_enabled` probe for 30s — only the *enabled* result is ever cached, so a stale read can only ever fail closed (require auth), never open, and any toggle invalidates it immediately; and (c) adds a `GET /api/v1/system/db-pool` diagnostic exposing the resolved config plus live `checked_out` / `checked_in` / `overflow` gauges (read without checking out a connection, so it stays truthful under saturation). Note: connections being held across slow MQTT/FTP/camera/3MF work — the underlying reason transactions sit idle — is a deeper session-hygiene change tracked separately; this drop relieves and instruments the problem and makes the farm sizing configurable. See the PostgreSQL wiki page for large-farm tuning and the required `max_connections` headroom. - **P1S camera still black on every page load, recovering only after ~20 minutes (#2521, reporter @nnimby848)** — The previous round of fixes did not take, and the reporter re-tested on two daily builds to say so. The fan-out barrier added last time — a replacement stream waits for the displaced one's socket to close before dialling, so a printer that allows a single camera connection never sees two at once — was correct, and was being **bypassed**. `shutdown_broadcaster()` *popped* the broadcaster out of the registry and only then awaited its teardown, so for the duration of the socket close the registry slot sat empty. A `/camera/stream` request landing in that window found nothing, minted a broadcaster with no predecessor to wait for, and dialled port 6000 immediately. The barrier only engages when the displaced broadcaster is still findable — and the one path that tears a stream down on purpose removed it first, disabling the barrier in exactly the case it was written for. A page reload fires `/camera/stop` and the new `/camera/stream` **concurrently**, which is why it reproduced on essentially every load. The printer then held two connections, kept feeding the orphan, and starved the live viewer: the new socket connects (the reporter's logs show `Chamber image: connected`) and then receives nothing until the printer's TCP keepalive reaps the dead one — **his 20 minutes, to the minute**. The stopped broadcaster now stays in the registry so the next viewer chains behind its socket close, which is what the barrier always intended. Pinned by a test that counts *actual* sockets through the real stop-then-restream race and fails with `2` against the old code; the existing barrier tests placed the broadcaster into the registry by hand, which is precisely why they never caught this. - **Every camera page load attached two viewers and abandoned one (#2521)** — Found while reproducing the above, and the reason it fired on *every* load rather than occasionally. The stream-token query runs whether or not authentication is enabled, and the camera page subscribes to it: the first render produced an `` with no token, the token arrived, and the re-render **changed the src**. The browser aborts the in-flight request and issues a second one — and with auth disabled no token is required, so *both* reached the backend and attached to the fan-out. The reporter's HAR shows it exactly: two requests to the same stream URL, same cache-buster, one without `token=` and one with. His backend log shows the consequence, `subscribers=2`, on a printer that allows one connection. The src is now rendered only once the token query has settled — one URL, one request, one viewer — and an auth-disabled install whose token endpoint fails still streams, because it never needed a token. - **A viewer that left during a black stream stayed counted for 30 seconds (#2521)** — Also found on the way. A subscriber only checked whether its client was still connected *after* it had yielded a frame, or when a 30-second idle timeout fired. So a browser that walked away while the stream was producing nothing — the exact situation above — went on being counted as an attached viewer for up to half a minute. That matters beyond tidiness: `/camera/stop` consults the subscriber count to decide whether to tear the upstream down, so a phantom viewer could make it skip the teardown entirely and leave the socket open. Disconnects are now noticed within a second even when no frames are flowing. - **"Please login." when importing from MakerWorld — while Bambuddy said you were connected to Bambu Cloud** — An expired Bambu Cloud token was indistinguishable from a working one, so the UI reported "Connected as ..." indefinitely while every cloud call was being rejected. The toast you got was Bambu Lab's own words, forwarded verbatim: their 401 body is `{"code":4,"error":"Please login.","message":""}`, and we passed the `error` field straight through — which read as Bambuddy telling you to log in, next to an indicator saying you already were. **The status was never real.** `set_token()` stamped `token_expiry = now + 30 days` *every time a stored token was loaded from the database* — re-derived from the current moment, for a token of entirely unknown age — and `is_authenticated` was "we have a string, and we're not past that expiry". The expiry reset on every request, so the check could never fail. It was a string-presence test wearing an expiry costume, and `/cloud/status` answered `true` for as long as any token existed. Bambu's access token is opaque (no readable claims), Bambu's login response carries no expiry, and Bambuddy discards the `refreshToken` it is handed, so nothing else in the system knew either. When a token lapsed — Bambu's own comment in our code says they last around three months — **every** cloud feature died at once (MakerWorld imports, cloud profiles, slicer presets, firmware checks) with no signal anywhere that a re-login was needed. **Bambu is now the authority.** No expiry is invented. `/cloud/status` asks Bambu whether the token is still accepted, cached for five minutes so the several components polling it don't each pay a round-trip, and any 401 from any authenticated cloud call durably records the credential as dead — so the whole app agrees at once instead of each feature failing separately. An unreachable Bambu, a 5xx, or a Cloudflare challenge is treated as *unknown*, never as *expired*: an outage must not sign a working session out. The Profiles page now explains why the login form is back, MakerWorld says the sign-in expired rather than that one is required, and its import buttons stop pretending they can download. The user-facing message names the **Profiles** page, where the Bambu Cloud sign-in actually lives — the old fallback text pointed at "Settings → Bambu Cloud", which does not exist. - **Importing from MakerWorld failed on Windows with a certificate error (#2562)** — Paste a MakerWorld URL, click Save, and the import dies with `S3 download failed: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate`. Only native Windows installs are affected; Docker never sees it. The import walks several hosts, and the failure is at the last hop: Bambu Cloud answers the download request with an **AWS presigned URL**, and that one URL is fetched with `urllib` rather than httpx, on purpose — S3 signs the exact query-string bytes, and httpx re-encodes them into a `SignatureDoesNotMatch`. What that swap quietly also changed was the **trust store**. httpx — every other network call in Bambuddy, including the `api.bambulab.com` calls that succeed immediately before this one — verifies against the bundled `certifi` CA bundle. `urllib` verifies against the *operating system's* store, and on Windows the two disagree: Python's `ssl.load_default_certs()` only enumerates the roots already cached in the Windows ROOT store, which Windows fills in lazily through CryptoAPI's auto-update — a mechanism Python never triggers. On a machine where the Amazon root signing the S3 chain has not been cached yet, verification fails with exactly the error above. Linux images ship a complete `ca-certificates` bundle, so the OS store and certifi agree and the bug is invisible there. The S3 hop now verifies against certifi too, so it trusts precisely what the rest of the app already trusts. Verification itself is untouched — the certificate is still checked and the hostname still matched; the fix changes where the CA list comes from, not whether TLS is enforced. The URL still reaches the transport byte-for-byte, so the S3 signature is unaffected, and the no-redirect guard that keeps the download-host allowlist meaningful is unchanged. `certifi` is now an explicit requirement rather than one inherited from httpx, so a future httpx release cannot drop it out from under this import. - **Prints on a multi-printer farm started one by one, up to an hour apart (#2555, reporter @Maxtrim3D)** — Start a batch across several printers and they trickle out one at a time; the more printers, the worse it gets. Not a misconfiguration, and nothing in the wiki could have helped: the scheduler awaited each dispatch inline in its selection loop, and a dispatch includes the FTP upload. So every printer queued behind every other printer's transfer, even though they are entirely independent machines. **The arithmetic is the whole bug report.** A Bambu printer's FTP server sustains around 150 KB/s — its own SD-card write is the bottleneck, not the network — so the reporter's 41 MB `.3mf` took **254 seconds per printer**, straight from his logs (`40978500 bytes in 254.1s, 157 KB/s`). Nineteen printers in series is roughly **80 minutes** before the last one starts, which is exactly the "up to 1 hour" he reported, and exactly why it got worse the more printers he selected — the delay is linear in fleet size. The logs show the next upload beginning **131 ms** after the previous one finished, back to back, forever. **Uploads to different printers now run concurrently**, capped by a new **Settings → Workflow → Queue & Dispatch → Concurrent Uploads** value (default 4, up to 16; set it to 1 for the old strictly-serial behaviour if your network or host cannot take parallel transfers). Selection is unchanged and still sequential — only the transfers overlap — so every existing gate (busy printers, plate-clear, filament deficit, shortest-job-first, staggered start) behaves exactly as before, and a queue pass still finishes all of its uploads before the next one begins, which is what stops the same still-`pending` row being dispatched twice. FTP work also moves off asyncio's shared default executor onto its own pool: that executor is sized `min(32, cpu_count + 4)` — six threads on a 2-core NAS — and is shared with everything else in the app, so parallel uploads would have parked one thread each, for minutes at a time, and starved unrelated work. - **A printer that accepted a file but never started printing was retried forever (#2555)** — Surfaced by the same reporter: "I have a printer who, since the morning, still not launch." When a printer takes the file (its `subtask_id` advances) but never actually begins, the start-watchdog waits 270 seconds, reverts the queue item to `pending`, and the next pass re-uploads the entire file and waits it out again — with **no attempt limit**. For a genuinely wedged printer that loop never terminates, and on a farm each lap also consumes an upload slot the other printers are queueing for, so one stuck machine dragged out everybody else's start times. Retrying is right; retrying forever is not. Attempts are now counted on the queue item: the transient causes the watchdog already recovers from (a publish lost on a half-broken MQTT session is fixed by the forced reconnect on the very next try) still get their retries, but after **three** the item is failed with a message pointing at the printer — check its screen for a prompt or error, and check the SD card — rather than being handed back to the queue a fourth time. - **A queued library print with no readable print time crashed the dispatch — and took the rest of that queue pass down with it (#2555)** — Found while reviewing the above. Starting a print from a library file read `library_file.print_time_seconds`, a column `LibraryFile` does not have (its print time lives in the file's parsed metadata). It only fired when the archive carried no print time of its own — a plain `.gcode`, or a 3MF the parser could not read — and it fired *after* the job had already been sent to the printer, so the print itself ran but the "print started" notification was lost. Worse, the error unwound the whole queue pass: every other printer still waiting to be dispatched on that tick silently missed its turn and had to wait for the next one. It now uses the print time the queue item already caches. The concurrent-dispatch change above independently contains this class of failure — one printer's dispatch blowing up can no longer cancel its siblings' in-flight uploads. - **A print mapped to a different filament than it was sliced for was logged under the sliced material, not the one actually used (#2563, reporter @alexfilimon)** — Slice a model for **Bambu PLA Basic**, open Filament Mapping in the Print dialog, and — because no PLA was loaded — hand-pick the only loaded **PETG** slot. The printer prints from PETG, the PETG spool is correctly debited, but the Archive card, the Print Log and the material statistics all still call the run **PLA**. So "filament used", the one label that should describe what left the spool, described what the slicer asked for instead. The archive's `filament_type` is stamped once from the 3MF at creation and never revisited; the Print Log copies it verbatim at completion and the stats group on it. The material Bambuddy actually consumed was known all along — usage tracking resolves every used slot to the spool that fed it and already carries that spool's `material` — it just wasn't being written back. This is the exact problem that was solved for filament **colour** a while ago (#1494): once usage tracking has matched every used slot to an inventory spool, the spool's curated colour replaces the slicer's, so an archive printed from a `#000000` spool stops showing the slicer's near-black. Material now does the same. When every slot with non-zero usage resolves to a spool that declares a material, the archive's `filament_type` is rewritten from those spools — slot-ordered, de-duplicated, comma-joined exactly like the colour and the original type — and because that rewrite is committed before the Print Log entry is written, the corrected material flows through to the archive card, the Print Log and the stats with no further work. **All-or-nothing, deliberately**, mirroring the colour path: if even one used slot can't be resolved to a spool with a material, nothing is rewritten, so a partial match can never drop a slot's type from the archive or the material graph. A run whose mapping matched the slice is a no-op (the rewrite equals what's already there). **Both inventory backends, same drop.** The built-in Spool inventory does it from the matched spools' `material`; Spoolman does it from the resolved Spoolman spool's `filament.material`, captured at the same point the spool is already fetched for its colour, so no extra Spoolman round-trips. The remain%-delta fallback (no-3MF "Untitled" prints) intentionally sits it out in both backends, exactly as it does for colour — those prints have no 3MF slot to attribute a material to. **Tests.** 7 on the internal helper (the reporter's PLA-slice-to-PETG-spool case; slot-ordered de-dup across a multi-material print; the all-or-nothing gate leaving a partially-matched print untouched; a zero-usage slot needing no spool; no-used-slots and slot_id-less fallback results both declining to rewrite; a blank material not counting as a match). 3 on the Spoolman archive rewrite against a real DB session (a PETG spool overwrites a PLA slice; a partial match leaves `PLA,PLA` untouched; an empty material map is a no-op). Existing usage-tracker and Spoolman suites unchanged and green. - **Every job on a busy farm waited up to 30 seconds after a printer freed up before it was sent (#2555, reporter @Maxtrim3D)** — With the parallel-upload fix in, the reporter still saw prints take "several long minutes" to leave the queue, sometimes going out together and sometimes in dribs. The scheduler's main loop did its work and then slept a **fixed 30 seconds** before looking again, unconditionally. That interval is dead air: a printer that finished a job one second after a pass ended sat idle for the next 29 before its follow-on print was even considered, and a batch fanning out across a fleet — where printers free up a few seconds apart as their current jobs end — dispatched in 30-second steps regardless of how fast the machines were actually becoming available. On nineteen printers that is minutes of nobody-is-uploading time stacked on top of the transfers. The loop now **re-checks within a few seconds whenever the previous pass actually dispatched something**, and only falls back to the 30-second idle sleep when a pass sent nothing. So a draining batch keeps moving at the speed the printers free up, not at the speed of a fixed timer. This cannot become a busy-loop: the fast tick fires *only* after a productive pass, and a pass is productive only while there is ready work to send — the moment the remaining items are all behind printers that are genuinely busy printing (or behind a wedged head-of-line job holding its printer in the post-dispatch cooldown), the pass dispatches nothing and the loop reverts to the slow interval. Selection, the concurrency cap, and the finish-all-uploads-before-the-next-pass invariant are all untouched; only the gap between passes shrinks when shrinking it helps. **Tests.** 2 new cases: a pass that dispatches three items reports that it did (so the caller re-ticks fast), and an empty queue reports that it did not (so it sleeps normally). The existing concurrent-dispatch suite — parallel fan-out, the cap, the serial escape hatch, one-failure-doesn't-cancel-siblings, and the uploads-finish-before-return invariant — passes unchanged against the new return value. - **Debug logging was unusable on a large fleet, and the support bundle only shipped a fraction of what was on disk (#2555)** — We asked the reporter to turn on debug logging and send a support bundle. The bundle came back holding **4 minutes 49 seconds** of history — barely one upload — for a problem that takes an hour to unfold. Two causes, both fixed. The state dumps in the MQTT push_status handler fired whenever their field was *present* in a frame, and a full frame carries every field, so they fired on **every frame** regardless of whether anything had changed; several said "updated" or "when X changes" in their own comment while doing nothing of the sort. On one printer that is ~1.5 lines/s and invisible. On nineteen it is ~100 lines/s: **27,727 of the bundle's 29,830 lines** were these dumps, and they rolled the 5 MB log over in under five minutes. They now log transitions only — every change is still recorded, the steady-state repetition is not. Separately, the bundle shipped only the live `bambuddy.log` and ignored the three rotated backups sitting next to it, even though its own byte budget was four times larger than the file it was reading; it now spans the rotation, oldest first, spending the budget on the most recent history. - **Filament Override vanished for a multi-plate selection in Any [model] mode — but only on the second visit (#2552, reporter @bondjw07)** — Open a sliced multi-plate `.gcode.3mf`, pick **Any [model]**, tick two plates, and the whole Filament Override section is gone. Tick one plate and it comes back. The reporter tied it to having queued or printed the file before, which is the real clue, but not the cause: what actually mattered was that the dialog had been opened once already, so the plates data was still in the cache. The filament requirements are fetched under a key that carries the selected plate, and that key is `null` as soon as two plates are ticked. On the first open the plates are not yet known, so for one render the modal cannot tell it is a multi-plate file and fetches the requirements for the whole file — the union of every plate's filaments — which the override panel then rendered from. On the next open the plates are already cached, the modal knows it is multi-plate from the first render, the whole-file fetch therefore never happens, and the panel had nothing to render. So the section's visibility was decided by a cache race, and the case that "worked" was showing you filaments from plates you had not selected. **Both halves are now wrong-free**: a multi-plate selection in model mode renders one **Filament Override — Plate N** panel per selected plate, each fetched for that plate and listing only the slots that plate actually prints, identical on a cold and a warm cache. A slot's chosen filament and its Force color match tick are shared across plates that print that slot — slot ids are global to the file, so slot 3 is the same filament wherever it appears — and each queued plate is sent only the overrides for its own slots, so a colour forced for plate 2 no longer holds plate 1 back (the API narrows them per plate as of #2551, and the modal no longer sends them wide in the first place). Measured on the old code: warm cache, two plates → zero override panels; cold cache → one panel listing both plates' filaments. Now: two panels, one slot each, either way. **Four further holes in the same per-plate machinery closed while reviewing it**: a manual tray pick on one plate survived a change of printer, and a global tray id names a different spool on a different machine — so the job went out on a tray nobody chose; a plate whose filaments could not be read (or had simply not loaded yet) was indistinguishable from a plate needing none, and was queued with no mapping and no forced colours, to print in whatever happened to be loaded — the Print button now waits for every selected plate to answer and says which one could not be read; the "not enough filament left" check still weighed the whole file's filaments against a mapping the plates no longer use, so it either failed to warn at all or warned about trays the print would not touch — it now follows what each plate actually dispatches, and sums the demand per tray, because 60 g left does not cover two plates of 40 g even though it covers either one of them; and the per-printer tray editor still appeared for a multi-plate fan-out, collecting tray choices that were then discarded. - **Queueing several plates of one file mapped them all through the first plate's filaments — and hid the panel that would have shown you (#2551, reporter @bondjw07)** — Select one plate and the Filament Mapping panel appears; select a second and it vanishes, and in **Any [model]** mode it never appears at all. Both were deliberate, and one of them was covering a wrong-tray dispatch. **Why the panel hid.** It maps one set of 3MF slots onto one printer's AMS trays, so it needed a single plate and a single printer; `selectedPlates.size <= 1` hid it the moment you ticked a second plate. In model mode there is no printer selected, so there are no trays to map onto — that one is legitimate, and the scheduler computes the mapping per plate when it picks the printer. **What the hidden panel was hiding.** The modal kept posting an `ams_mapping` anyway. With two plates selected the modal has no single plate to ask about, so it falls back to the whole file's filament list — the **union of every plate** — and matched against that. Tray assignment is stateful: a tray claimed by one slot is not offered to the next. So for a file where plate 1 prints red on slot 1 and plate 2 prints red on slot 2, slot 1 took the only red spool and slot 2 fell through to a type-only match on **black** — and that one mapping, `[red, black]`, was sent with *both* plates. The scheduler uses a stored mapping verbatim and only computes its own when the item has none, so plate 2 printed in the wrong colour, decided by a panel the user was never shown. Measured, not deduced: driving the old modal with a real cache posts `ams_mapping: [0, 1]` for both plates. (It reproduces only with a realistic React Query cache — the test harness's `gcTime: 0` evicts the union and makes the modal look innocent, which is why this hid for so long.) **Now each plate maps itself.** Select several plates on one printer and you get one mapping panel **per plate**, named after it, each showing and mapping only the slots its own plate prints, each with its own tray overrides — pin plate 2's red to a different spool and plate 1 is untouched. Each queue item carries its own plate's mapping. Fanning several plates across several printers would be a panel per plate per printer; those items are queued with **no** mapping instead, and the scheduler maps each plate against the printer it actually dispatches to, which it already does correctly. **One matcher, not three.** The tray-matching logic existed twice (once in the hook, once in `computeAmsMapping`) and this needed a third caller, so it is now extracted once and both paths delegate to it — the per-plate panel and the per-printer fan-out cannot drift apart. Its 62 existing tests pass against the extraction unchanged. **Tests.** 3 on the matcher, pinning the exact divergence: each plate alone maps to the red tray, the union starves the second slot onto black, and a manual override on one plate does not leak into another. 4 on the modal: one panel per selected plate; each plate posts the mapping for its own slots (`[0]` and `[-1, 0]`, not the union's `[0, 1]`); a multi-printer fan-out posts none; a model-assigned job posts none. Mutation-verified against a production-like cache — the per-plate test fails with exactly the old `[0, 1]`, and removing the multi-printer guard leaks printer 1's trays onto printer 2. - **Queueing several plates of one file with Force color match made every plate wait for every colour (#2551, reporter @bondjw07)** — A sliced multi-plate `.gcode.3mf`, each plate a single different PLA colour, queued to **Any X1C** with **Force color match** on. A printer with Army Blue loaded and idle should take the Army Blue plate. Instead every plate sat at Waiting on `PLA (Army Blue), PLA (Ash Grey), PLA (Sunshine Yellow)` — the colours of *all* the plates. Queue the same plates one at a time and it works, which is the tell. **One override list, handed to every plate.** The print dialog only tracks a selected plate when exactly one is selected; pick several and it asks the backend for the filaments of the *whole file*, which is the union across all plates. It builds its override list from that union — correctly, because the user does need to tick each colour once — and then posts **that same list** with each plate's queue item. A `force_color_match` entry means "do not dispatch until this printer has this exact colour loaded", and the scheduler enforces *all* of them, so each single-colour plate demanded the whole batch's palette. The reporter's own guess in the issue was exactly right. **The API is what fixes it.** The overrides are now narrowed to the slots the item's plate actually prints, at write time, on both create and edit — the backend is where the 3MF is, so this holds for every writer of the queue and not just the one dialog. Nothing changes for a single-plate job or for a whole-file job, where the union *is* the requirement. **A second, quieter version of the same bug.** Override types are merged into the item's `required_filament_types`, which is the gate that runs *before* colours are even considered. A shared list therefore also widened that gate: queue a PLA plate and a PETG plate together and the PLA one would refuse every printer that didn't also have PETG loaded, with no mention of colour anywhere in the reason. Narrowing the overrides closes that too. **Fails strict, never silent.** When the plate's slots can't be established — corrupt 3MF, source file gone — the overrides are kept whole rather than dropped. An item waiting on a colour it doesn't need is visible and fixable in ten seconds; an item that silently lost its forced colour prints in the wrong filament. **The plates already in your queue are repaired on upgrade.** Fixing the write path alone would have left every item queued before this release sitting exactly where it is — stuck, with a waiting reason that explains nothing — until the user worked out for himself that deleting and re-adding them was the cure. A startup migration re-scopes the pending items instead. Items that are already printing or done are left untouched: their overrides are a record of what they dispatched with, not an instruction. **Tests.** 6 cases on the API (each of three plates keeps only its own colour and its own slot id; a whole-file job still keeps all three; an unreadable 3MF keeps all three; a PLA plate's required types stay PLA when a PETG plate is queued alongside it; editing an item narrows its overrides too; moving an item to another plate re-scopes it). 5 on the repair (three stuck items each come back to their own colour; a second boot is a no-op; a printing item is not rewritten; a whole-file item keeps all three; a missing source file strips nothing). Mutation-verified — six of the eleven fail against the old code. The migration was run against a real PostgreSQL 16 as well as SQLite, twice over, to confirm it is dialect-neutral and idempotent. - **A project's tags vanished from the edit dialog when you opened it from the projects list — and its priority was quietly reset when you saved (#2536, reporter @fireboyff)** — Editing a project from the Projects list showed an empty tags field; opening the same project first and editing it from inside showed the tags correctly. **One dialog, two callers.** `ProjectModal` is shared: the detail page hands it a full project, the list hands it a list item. The list endpoint's payload never carried `tags`, `due_date` or `priority`, so from the list the dialog seeded those three fields from `undefined` and rendered them blank. It compiled because the component read them through a cast (`project as ProjectListItem & { tags?: string }`), which asserts a field the type does not have — so TypeScript never pointed out that the value was always missing. The fields are now on `ProjectListResponse` and on `ProjectListItem`, the casts are gone, and the compiler enforces the two shapes agreeing from here on. **The part nobody reported.** The dialog does not send tags when the field is empty, so the tags themselves survived — they were only invisible. Priority is not so lucky: it is *always* sent, defaulting to `normal`. So editing a **high** or **urgent** project from the list silently demoted it, and the reporter would have had no reason to connect that to the empty field he did see. Fixing the payload fixes both, since the dialog now receives the real priority to send back. **Clearing a tag list also never worked, from either view.** An emptied field was sent as `undefined`, which drops the key from the request, and the backend only applied values that were not null — so the old tags came straight back. Tags and due date now behave like budget and URL already did: sent as null, cleared explicitly, and an omitted key still means "leave it alone". **Tests.** 4 backend cases (the list and the template list both carry the fields the dialog renders; a partial update does not disturb a stored priority or tags; an explicit null clears tags and due date) — mutation-verified, three of them fail against the old payload. 3 frontend cases pin the dialog: it prefills all three from a list item, it round-trips a stored `high` instead of submitting its default, and clearing the tags field sends null. The templates list was missing `target_parts_count` too, which the same dialog edits; that is fixed in passing. - **Scheduled backups to a NAS failed with "Read-only file system" — and our own systemd unit was the reason (#2544, reporter @pwostran)** — Nightly backups to a mounted NAS share had run since May and then stopped, failing every night with `[Errno 30] Read-only file system`. The reporter checked the folder permissions, which were correct: his mount is `gid=backup,dir_mode=0775`, the service user is in `backup`, and his own shell writes to the share fine. **Errno 30 is EROFS, and EROFS is not a permission error** — a permission problem is errno 13. EROFS means the filesystem itself refused the write, and the filesystem refused it because *we told it to*. Bambuddy's systemd unit ships `ProtectSystem=strict`, which mounts the entire filesystem read-only inside the service's own mount namespace and carves back out only `ReadWritePaths= `. 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-`) 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 SIGKILL** — `CMD ["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 down` → `WAL checkpoint completed` → `Application 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 `exec`s, 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 - **Russian (Русский) UI translation (#2608, contributor @pterodaktil02)** — Bambuddy's interface is now fully available in Russian, bringing the total to 11 languages. Pick it under Settings → General → Language. The translation covers the whole UI — printer controls and statuses, build plate and bed, filament, and AMS — with context-appropriate terminology throughout, and preserves every interpolation placeholder so counts, names, and progress values render correctly. - **"Slice as designed" — keep a MakerWorld author's own settings when you slice server-side (#2611, reporter @kpp39)** — When you slice a project 3MF through Bambuddy, the SliceModal makes you pick a printer / process / filament triplet, and the slicer applies those with `--load-settings` — which *overrides* whatever the designer baked into the file's `Metadata/project_settings.config`. So a MakerWorld model set up for 5 walls came out at the picked profile's 2, and the reporter's own re-posted files lost their tweaks too. That override is correct for the flow's main job — re-slicing someone else's design for *your* printer and AMS, especially across models (an H2D design onto an X1C) *needs* the bed size and filaments swapped — but it left no way to say "just slice it the way the author set it up." **What's new.** When the source 3MF carries embedded settings **and** the picked printer matches the design's target model, the modal offers a **Use the file's built-in settings** checkbox. Tick it and Bambuddy slices with no `--load-settings` override, so the designer's walls / infill / filament choices drive the result; all four preset controls (printer, process, filament, bed type) grey out to show they're bypassed — the printer included, since it's unused on this path and changing it would only pull you off the design's target and hide the toggle again. **The printer-match gate is deliberate.** Honouring embedded settings only makes sense when your printer *is* the design's printer — applying them across models would drop the object on the wrong bed, which is the whole reason the profile path exists — so the toggle simply isn't offered otherwise, and there's no cross-printer re-targeting on this path. Filament comes from the file too, not your AMS picks; the hint says so. **Under the hood** this reuses the existing embedded-settings slice path (previously only a crash fallback) as a first-class, user-selectable mode; the response already flagged `used_embedded_settings`. **Not** in scope: merging a picked filament *over* the designer's other settings — that needs per-key precedence and is a separate future enhancement. **Scope.** One backend request flag + one branch, one gated frontend checkbox. No DB migration, no new permission, no new setting. Two new i18n keys (`slice.useEmbedded`, `slice.useEmbeddedHint`) translated in all 11 locales. - **A paused AMS runout now names the physical slot the printer is actually waiting for (#2587, reporter @Jostxxl)** — When a spool runs out mid-print, Bambuddy showed the firmware's generic HMS text — "insert a new filament into the same AMS slot" — which is exactly wrong when AMS Filament Backup is on: the firmware won't re-accept the depleted slot and advances to the next compatible one, so "the same slot" sends the operator to the wrong place. On the reporter's farm this meant reinserting into Slot 2 (where it ran out) did nothing, and the print only resumed after moving the spool to Slot 3 — with no on-screen hint that Slot 3 was what the printer wanted. **Root cause.** The printer's AMS payload carries `tray_tar` (the slot the paused print now expects) and `tray_pre` (the slot that ran out) right next to `tray_now`, but Bambuddy parsed `tray_now` only and dropped the other two at ingest, so "which slot does the print expect" never reached the API or the UI. **What changed.** `tray_tar`/`tray_pre` are now captured on printer state and, while the print is **paused**, resolved to global tray IDs and surfaced on the status payload (both the REST poll and the live WebSocket push) as `expected_tray` / `previous_tray`. The AMS graphic highlights the expected slot with a pulsing amber ring (and a down-arrow badge) and marks the ran-out slot in red, and the HMS error is re-described to name them directly — e.g. "Filament ran out in AMS-A · Slot 2. The printer is now waiting for compatible filament in AMS-A · Slot 3. Insert a spool into AMS-A · Slot 3, then select Retry." **Honest when it can't tell.** On a single regular AMS the reported slot is already the global ID; on multi-AMS it's a local slot that's resolved against the print's snow-encoded mapping field, and AMS-HT IDs (128–135) pass through. When the slot can't be resolved unambiguously (multi-AMS with no usable mapping), the graphic highlights nothing and the message says so — "Bambuddy could not determine which slot the printer now expects — check the printer screen" — rather than pointing at a guess. User AMS friendly-names are honored in the labels. **Scope.** Guidance is populated only while paused, so a healthy print's normal target churn never highlights a slot or spams the log. Backend resolver, the ingest parse, and the modal re-description are covered by new unit/component tests; the runout copy is translated in all 11 locales. - **The sponsor surfaces now ask a print farm a different question than they ask a hobbyist** — Since the in-app sponsor banner and milestone toast shipped in v0.2.4.8, both have made exactly one ask, to everyone: chip in a few dollars to keep Bambuddy independent. That ask works — new sponsorships went from 0.40/day to 1.40/day in the fifteen days after the release, and clicks through to GitHub Sponsors rose 4.3x on a *falling* web traffic base. But it is the wrong ask for part of the audience. Someone running twelve printers as a business does not want to donate $5; they want a support contract, an invoice, and somebody accountable when the line stops. They were being shown a donation button and, unsurprisingly, ignoring it. **What changed.** At **5 or more configured printers** the Settings → General banner and the milestone toast both make the commercial ask instead — priority support, commercial licensing, invoicing — and link to the new **bambuddy.cool/business.html** rather than the sponsor tiers. Below that, nothing changes at all. It is the same single interruption either way: same milestones, same 14-day cooldown, same one-toast-per-session guard. Only the ask changes, so nobody sees more nagging than before. **Configured printers, not active ones.** The count deliberately ignores `is_active`, which is the maintenance-mode flag rather than a fleet-size signal. A farm with eight machines and five of them on the bench for nozzle swaps is still a farm — filtering on `is_active` would have counted three, downgraded them to the hobbyist pitch, and done it precisely when they were having the worst day. **The page concedes the licence up front.** business.html opens by stating plainly that Bambuddy is AGPL-3.0, that running it inside your own business costs nothing, and that no licence is required no matter how many printers you have — because that is true, and a page that implied otherwise would be a lie the audience would catch immediately. What it then offers is the set of things a licence cannot give you: priority support with a named contact and agreed response times, commercial licensing for the narrow case where you actually need it (redistribution, OEM, shipping Bambuddy on an appliance), fleet deployment and custom development, and operator training. No price list — those conversations are scoped individually. **Attribution is preserved.** Both surfaces keep their existing Matomo `?from=` tags (`app-settings`, `app-toast-{milestone}`), so the business funnel is measurable from day one on the same dashboards as the personal one, and the split between the two is visible without any new instrumentation. **No telemetry was added**, and none is needed: fleet size is read from the printers list the app already has cached. **Scope.** Frontend only — no backend change, no schema change, no migration, no new permission, no new setting. The audience split is one shared helper (`utils/fleetAudience.ts`) so the threshold lives in exactly one place. **Tests.** 7 new cases: the boundary in both directions (4 printers → personal, 5 → business); the maintenance-mode trap (8 printers with 5 inactive still reads as business); the cold-cache race (the toast waits for the fleet to load rather than defaulting to zero printers and pitching a farm as a hobbyist); both banner variants including the assertion that the commercial copy *replaces* the donation copy rather than sitting beside it; and the `?from=` tag surviving on both paths. All 7 mutation-verified — forcing the threshold out of reach, or dropping the fleet-load gate, fails them. **i18n.** 4 new keys (`sponsors.toastBusiness`, `businessCta`, `businessTitle`, `businessTagline`) translated in all 11 locales; parity 5616 keys. - **Cam Wall on its own URL, and on a TV that isn't logged in (#2531, reporter @cadtoolbox)** — The Cam Wall was reachable exactly one way: click the Cam wall button on the Printers page. It had no URL, so you couldn't bookmark it, link to it, or point a wall-mounted screen at it. It now lives at **`/camwall`**, and a button next to the Cards / Cam wall toggle opens it there. Signed in, that page is the same wall you already know — tiles clickable, settings popover working, the knobs shared with the Printers page through the same localStorage keys, so a change in one follows you to the other. **The TV case is the hard half.** A screen in a workshop has no login session, and a wall tile needs two things a camera token could not previously fetch: the list of printers, and each one's status for the state badge. Both sit behind `PRINTERS_READ`, so a kiosk got a 401 and an empty wall. The obvious fix — let the existing `camera_stream` token through to `GET /printers` — is the wrong one: that response carries every printer's `serial_number` and `ip_address` even in its non-secret shape, and a URL pinned to a lobby TV lives in the browser history, in the kiosk's config file, and on the screen itself. So the Cam Wall gets a **purpose-built read-only feed** at `GET /api/v1/camwall/printers` that serves only what a tile draws: id, name, camera rotation, connected, state, progress, layers, remaining time, HMS codes. No serial. No IP. No access code. **And no filename** — a token wall renders the compact overlay, so the field simply isn't served rather than being served and then hidden client-side; the part on the bed is never named to a room anyone can walk into. **A second scope, not a wider one.** The feed is gated on a new `camwall` token scope alongside `camera_stream`. A Cam Wall token reaches the video *and* the tile metadata; a camera-stream token reaches the video and is refused by the feed. That matters because `camera_stream` tokens are already in the wild, minted by people who agreed to hand out a picture — shipping this must not retroactively grant them the ability to enumerate a fleet by name. Pick the scope when you create the token in **Settings → API Keys → Camera API Tokens**; the create dialog then hands you the finished kiosk URL, fully assembled, so nobody has to build it from the docs. **What a token wall gives up.** No settings popover and no click-through: a TV has nobody standing at it, and click-through would open a page the token cannot authenticate. The controls are not merely hidden — they aren't rendered, so a kiosk carries no focusable control it cannot act on. The overlay is capped at `compact` even if the URL asks for `full`. The screen can still be tuned from the URL: `?maxLive=9&interval=10&status=compact`, all clamped to the same ranges the popover enforces. **Statuses are polled, not pushed** — the page renders outside the app layout and its WebSocket provider, and a kiosk token cannot mint a WS ticket anyway; a wall is watched, not operated, so a 5-second cadence costs nothing. **Revoking the token cuts the display off** on its next request. **Tests.** 11 backend cases: no token / garbage token / revoked token all rejected; a `camera_stream` token refused by the feed (the assertion the separate scope exists for); a `camwall` token accepted; the payload's key set pinned so a future field can't quietly add a serial, an IP or a filename; a Cam Wall token passes the camera-stream gate so its own tiles fill; a camera-stream token still passes its own gate (regression guard on #1108); and the scope allowlist pinned so adding a third scope has to be a deliberate act. 7 frontend cases covering the kiosk feed being called with the URL token, the token reaching the `` 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-`