# Changelog All notable changes to Bambuddy will be documented in this file. ## [1.2.6b1] - Unreleased ### 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. - **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. - **Home Assistant sensors can be bound to a storage location, so a drybox reports its own humidity (#2824, requested and contributed by @MagicMelody84 in #2827)** — A temperature, humidity or battery entity can be attached to any storage location, and its reading shows where the filament in that location is already being looked at: on the spool cards in Inventory, and in three optional Inventory table columns that are off by default. Values can be colourised against the location's own thresholds, so a rack of dryboxes reads at a glance rather than one number at a time. Each binding carries its own alert rule — above/below for a numeric sensor, on/off for a binary one — and can raise a notification when it crosses into that state, on the edge only: a drybox that was already too humid when Bambuddy restarted has not just become too humid, and re-announcing every pre-existing alert on every restart is how people learn to ignore the alert. That notification is its own event with its own provider toggle and template rather than sharing the printer sensors' one, because a provider can be scoped to a single printer and a location has no printer to scope by — sharing the column would have delivered drybox alerts to a channel deliberately narrowed to one machine, with no way to have one without the other. A background poller reads the bound entities on a configurable cadence, defaulting to two minutes and floored at one, and polls before it sleeps so a restart does not leave every card blank for a full interval. Nothing is polled for a location that has no sensor bound, and in table view nothing is polled at all while the three columns are hidden, so the installs that never touch Home Assistant — which is most of them — issue no extra requests. New bindings are seeded from defaults that live on the server, not in the browser, since they become the alert rule written onto each row and two admins binding sensors from different machines must not seed different rules; the colour choices stay per-browser, because those are only how one person likes to read the page. Home Assistant's `moisture` class is deliberately not offered: it is a binary wet/dry leak detector rather than a humidity percentage, and treating it as one put "wet" in a percent-formatted column and let it block the real hygrometer on the same location. One entity can be bound to a location once, enforced by a unique index rather than by the check alone, because that check is read-then-insert and two concurrent creates could both pass it; the upgrade collapses any duplicate that slipped in before the index existed instead of failing to build over it. An API key can read the bindings but not create or edit them, matching the printer bindings exactly. Translated in all 13 locales, with backend and frontend regression tests throughout. - **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. - **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. ### Changed - **Updating now repairs a service file that was written before the `--loop asyncio` pin existed (#3001)** — `install.sh` has pinned the loop since 2026-07-05 (#1896), but nothing has ever rewritten an *existing* service file: `install/update.sh` does a `git reset --hard`, a pip install, a frontend build and a restart, and never touches the unit. uvloop has been in every native venv since `uvicorn[standard]` entered `requirements.txt` on 2025-11-28, and uvicorn's `--loop auto` prefers it, so every native install created in that seven-month window has been running on uvloop ever since and no amount of updating has changed that. It cost them every RTSP camera on 1.2.5.4, and before that it left them exposed to a Virtual Printer FTP upload being silently truncated into a corrupt `.gcode.3mf` — which is why #1896 shipped a ZIP-validation backstop alongside the pin, since the pin could never reach the installs that already existed. Both update scripts now add the missing flag themselves: `update.sh` to the systemd unit, `update_macos.sh` to the launchd plist, in each case while the service is stopped so the repair takes effect on the same restart. Only the one flag is ever inserted — a hand-edited port, extra hardening, `ExecStartPre` lines and everything else stay byte-identical, and the file is copied to a timestamped backup first. Anything that is not a single-line unit invoking uvicorn directly is described rather than edited: a wrapper script, an `ExecStart` continued across lines, several `ExecStart` lines, a unit that is not writable, or a service carrying systemd drop-ins, since a drop-in may be what defines `ExecStart` and editing the fragment would then change nothing while reporting success. A loop pinned deliberately is also left alone — someone who wrote `--loop uvloop` on purpose gets no argument, only the startup warning. The check reads the *effective* `ExecStart` from systemd rather than the file, so a drop-in that already pins the loop counts and the repair is idempotent. - **Bambuddy now says so at startup when it is running on uvloop (#3001)** — every unit file this project ships pins `--loop asyncio`, added for #1896 because uvloop's SSL layer can drop buffered data and truncate a Virtual Printer FTP upload into a corrupt `.gcode.3mf` that is acked `226` and forwarded to a printer. Two populations run a unit nobody here wrote and therefore have no such pin: the Proxmox VE Helper-Scripts LXC, which composes its own `ExecStart`, and native installs created before that fix landed on 2026-07-05, which never gained the flag because `install/update.sh` does not rewrite unit files. Neither had any way to know. #3001 only surfaced them because losing every camera at once is loud; a truncated upload is silent, and there is nothing to notice until a print fails from a file that was corrupt on arrival. Startup now logs one WARNING naming the loop, the risk and the exact flag to add. It is a warning and not a refusal: a server that answers requests beats a purist one that will not boot, and by the time any application code runs uvicorn has already chosen its loop. The check asks the running loop what it is rather than whether uvloop imports — uvloop is a hard dependency here, since `requirements.txt` pins `uvicorn[standard]`, so its presence says nothing about what is in use — and it matches on the module name so that asking the question never imports uvloop on a host that lacks it. - **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. - **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. - **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable. ### Fixed - **Custom filament profiles arrived in the slicer as Generic, or as the Bambu profile they were built on (#3003, reported by @marivo)** — the slot's filament id is the one field a custom profile travels in, and it holds eight characters on the printer. Bambuddy was putting an eighteen-character preset identifier in it whenever it could not find a real filament id. The printer stored the first eight and reported success, so the slot pointed at something that resolves nowhere: the slicer showed Generic and the printer's calibration table, keyed by the same field, no longer had a slot to key. Three printers in the support archive show it happening — an A1, a P1S and an H2D — so it was never specific to one model. Bambuddy now sends the slot's existing filament id, or the generic one for the material, both of which fit. Orca Cloud profiles were additionally never looked up at all, so theirs went in as a thirty-six-character identifier and fared worse still; they are now resolved like every other source. Also fixed on the way through: a failed Orca Cloud sign-in left its HTTP connection behind instead of closing it, which went unnoticed while only the settings page could trigger it and would have repeated on every spool assignment once the lookup above started using the same code. A profile that carries no filament id of its own — which is every profile created in OrcaSlicer, whose preset format has no such field — still cannot be told apart from the one it inherits from. - **Hovering a muted control in the light theme made its label vanish (#1909, reported by @AntonPalmqvist)** — the tab strip on the Print Queue page was where it got noticed: point at **Batches** and the word turned white on a near-white background. It was never about that tab. The light theme keeps `text-white` readable by remapping it to the theme's foreground colour, but that remapping only ever matched the plain utility, not the `hover:` variant Tailwind compiles to a different selector — so roughly 400 controls across the app that dim their label at rest and brighten it on hover were brightening it to literal white, whatever the theme. They now follow the theme like everything else. Dark themes are unaffected, their foreground colour already being white, and the handful of buttons that turn a solid accent colour on hover read better for it rather than worse. - **Every camera stopped working on 1.2.5.4 (#3001, reported by @Jieper001, confirmed by @JmanB52D and @hikingthunder)** — live view, snapshots, timelapse frames and the camera diagnostic all failed at once on every RTSP model — X1, H2 and P2 — with the in-app diagnostic reporting `capture_exception` at 0 ms while network reachability passed at 1 ms. That 0 ms is the whole story: the failure happened before a socket was opened. The RTSPS proxy added in 1.2.5.4 finished by hanging its set of in-flight connection handlers on the server object as an attribute. `asyncio.start_server` returns an `asyncio.base_events.Server`, which has a `__dict__` and accepts that; under uvloop it returns a `uvloop.loop.Server`, a Cython cdef class with no `__dict__`, which raises `AttributeError` outright. Every launch path this repo ships — the Dockerfile, `install/install.sh`, `deploy/bambuddy.service`, the Windows service and the SpoolBuddy installer — pins `--loop asyncio`, added for #1896, so none of them selects uvloop and none of them could hit this. What broke is the installs running a unit file we did not write. The Proxmox VE Helper-Scripts LXC composes its own `ExecStart` with no loop pinned, and `requirements.txt` pins `uvicorn[standard]`, which installs uvloop on Linux, so uvicorn's default `--loop auto` selects it — that is the reporter's install and the two that confirmed it. Native installs created before the #1896 pin landed on 2026-07-05 are in the same position for a different reason: `install/update.sh` never rewrites the unit file, so a service written before that date has never been given the flag by any update since. Those installs are also still exposed to #1896 itself, where a truncated Virtual Printer FTP upload corrupts a `.gcode.3mf` silently; the camera outage is simply the visible half. Hence a fix in the code rather than another flag in a unit file: the proxy now works on either loop instead of depending on the launch command to steer around it. The handler set now lives in a module-level registry keyed weakly by server, which both loops accept; keying it weakly rather than by `id(server)` means a proxy abandoned without a close takes its entry with it, instead of leaking one forever and eventually handing a new server a dead one's handlers once CPython recycles the address. A1 and P1 use the chamber-image protocol and return before the proxy is built, so they were never affected, and external RTSPS cameras caught the error and fell back to a direct connection, so they kept working without the TLS workaround. The reason the test suite could not see any of this is that `conftest` builds its event loop from the default policy, so every async test in the repo runs on the selector loop — the one loop where the assignment was legal. The regression is now pinned twice: once by a test that drives the real function on a real uvloop loop, and once by a test that gives it a `__slots__` server, so the contract holds even where uvloop is not installed. The two external-camera teardowns were also switched to `close_tls_proxy`, which #2968 introduced and left them out of, so they no longer leave handlers running past the server that owned them. - **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 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 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. - **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. - **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. - **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. - **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. - **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. - **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. - **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. - **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. - **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. - **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 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). - **Load and Unload in the AMS slot menu did nothing on a printer with a Filament Track Switch** — The command that loads a slot carries an optional `extruder_id`, and Bambuddy never sent it. That is correct on every printer without the switch, and it is what BambuStudio does there too: each AMS is wired to one hotend, so the firmware works the target out for itself and an explicit value would only be a guess at something it already knows. Fit the switch and that stops being true — every AMS is then bound to one of the switch's two *inlets* instead of to a hotend, either hotend is reachable from any slot, and a load command that names neither leaves the firmware with nothing to act on. It was dropped in silence. Bambuddy now asks which nozzle to feed, in the same place and on the same terms as Bambu Studio: a small dialog with the two hotends, no preselection so an absent-minded Enter cannot feed the wrong one, and the hotend already fed from that very slot greyed out. Printers without a switch are untouched and still load in one click, because the field has to stay absent there. If the switch has been fitted but not yet set up on the printer — any AMS still not assigned to an inlet — the load is refused up front with a note to finish Manual AMS Setup, rather than sending a command that would go nowhere. Unload was addressed at the same time and for a related reason: it was aimed with the printer's single `tray_now` field, which names one slot for the whole machine, so on any dual-nozzle printer with both hotends loaded it unloaded whichever of the two that field happened to name regardless of which slot's menu was used. It now names the slot, and on a printer that has reported two hotends the one actually fed from that slot is resolved from the per-hotend loaded-slot report; a slot no hotend is holding is reported as such instead of unloading something else. Single-nozzle printers are untouched — with one hotend `tray_now` is already unambiguous, so there is nothing to resolve and nothing to get wrong. AMS-HT slots are untouched too: an HT unit is addressed by its unit id rather than by AMS-and-slot, which these commands do not accept, so unloading one still falls back to the printer-wide form it always used. - **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 `` holding a value none of its options match is reset by the browser to the first one, which here reads "No project", so an archive in an archived project would have stated that it was filed nowhere. The one picker deliberately left alone is the parent-project picker, where a finished or archived project is still a legal parent. Fixed alongside it: the status tabs on the Projects page counted only the projects the selected filter had already let through, so every tab but the current one counted zero and lost its badge entirely — the reporter's own screenshot shows "Active 5" beside a bare Completed and a bare Archived, with thirty projects behind them. They are counted from the whole fleet now. Covered by frontend tests. - **The full-page G-code preview grew without limit and never drew anything (#2887, reported by @ojimpo)** — Opening a 3D Preview from Archives left an empty white pane with the legend and layer slider floating over it, while the page's scrollbar shrank for as long as it stayed open — about 190px of page height per second, without stopping. Two faults compounded. The viewer appends its canvas into the very element it measures and watches for resizes, and three.js writes each new size onto the canvas as inline style, leaving it `display: inline` so the line box adds descender space on top; on a page where that element takes its height from its contents, the canvas was sizing the box that sizes the canvas, gaining a fixed 33px every round. And the page never gave it a height to take instead: the viewer pane is `flex-1 min-h-0`, which divides nothing unless the column above it is a definite height, and `h-full` is a percentage that resolves against a main area whose own height comes from a `min-height` — a floor, not a size — so it fell through to the content. Nothing was ever drawn because each observer callback reallocated and cleared the frame buffer before an antialiased render of what had grown to roughly 18 megapixels could finish. The canvas is now positioned out of flow, so it cannot contribute to the height of the element that measures it on any page, present or future, and that element takes a definite height from the pane around it rather than a percentage; the page itself is sized from the viewport the same way the File Manager page already was. The same viewer inside the File Manager dialog was never affected — a dialog gives it a fixed height, so neither fault could arise there. Nothing was wrong with the data path at any point: the legend and the layer slider were built from the parsed toolpath throughout, so the fetch, the parse and the layer split had all succeeded. - **A print could not start on the nozzle sitting in the H2C's rack (#2885, reported by @apizz)** — A job sliced for a 0.2mm nozzle failed in the queue with "install the matching nozzle before printing", even though a 0.2mm nozzle was in the tool-changer rack and the printer would have fetched it. Only picking the nozzle up by hand on the printer's own screen first let the print run, and because the item failed rather than waited, the rest of the queue went with it. The reporter noticed the giveaway: going *to* 0.4mm always worked, going *to* 0.2mm never did. The nozzle-diameter guard that catches a genuinely wrong slice before upload was measuring the wrong thing — it compared the sliced diameter against the two mounted hotends only, so on a machine whose hotends both read 0.4mm nothing but 0.4mm could ever pass, and the rack picker that would have fetched the right one runs further down the same dispatch and never got the chance. The rack now counts as reachable: a diameter parked in any dock satisfies the guard the same way a mounted one does. This was never only about 0.2mm — a 0.6mm slice was blocked identically. A diameter that is in neither a hotend nor a dock still stops the print before it uploads, and the message now lists both sets so it is clear what the machine actually has. Also fixed alongside it: an empty carriage keeps reporting the diameter of the nozzle it last held, so a hotend that had parked its nozzle back in the rack was counted as a mounted 0.4mm that was not there — presence is now read from the nozzle's own temperature rating and serial number, and a hotend is only discarded when both agree it is empty. Printers that report no rack at all are unaffected. - **An AMS slot could name the wrong white** — A hex is not one colour in Bambu's range: `#FFFFFF` is Jade White in PLA Basic, Ivory White in PLA Matte and plain White in six other materials, and `#000000` is Charcoal in PLA Matte where it is Black everywhere else. The slot popover looked the colour up by hex alone, and the lookup table can only keep one name per hex — so an ivory Matte spool was titled "Jade White" even while the profile line beside it correctly read Matte Ivory. The colour map now also carries the names that collapsing loses, keyed by material, and every slot resolves its colour with the material the printer reports for it (`tray_sub_brands`). Slots with a spool assigned from Inventory are titled with that spool's own colour name, which is the roll the user actually put in. Only a name the same brand's own range lost is carried — one manufacturer's name must not displace another's — so the added map is 11 entries against the 608 in the shipped catalog. - **A queued job switched on printers that could never have printed it** — When no printer of the target model was available, the queue powered one on via its smart plug, but chose it on model alone: it walked the farm in printer-ID order, woke the first machine with an Auto On plug, and only then discovered the loaded filament was the wrong colour. A job for a colour loaded at the far end of the farm therefore woke every earlier printer in turn and left each one running until its own auto-power-off timer expired. The colours were known the whole time — a printer keeps its last reported AMS and external-spool trays after the power goes — so the wake step now asks the same three questions the matcher asks a live printer (required types, forced colours, preferred colours) and passes over a printer whose last known filament cannot satisfy the job. A printer Bambuddy has never heard from is still woken: no reading is not the same as no filament. Reconnecting a printer also no longer discards that reading, so a power-on attempt that times out stops erasing what the next attempt needs. - **Archive metadata could describe a plate that was never printed** — The layer height on the archive card and in the library's file details came only from the 3MF's `project_settings.config`, and the plate G-code beside it was read for the layer count alone — the first `.gcode` entry in the zip, whatever plate the archive was actually for. A multi-plate export therefore reported plate 1's layer count even when plate 3 ran, and nothing ever cross-checked the layer height against the plate that produced the print. Both now come from the printed plate: its G-code is read (64 KB, enough to reach the config block that carries `layer_height` 14–25 KB in, where 4 KB only ever reached the header), and its value wins over the project's where the two disagree. Source 3MFs, which carry no G-code, keep the project value exactly as before. - **Slicing a file could ignore the process preset you picked** — Bambuddy carries a designer's own process deviations across a re-slice (#2622) and pre-ticked all of them except the machine-coupled ones. `layer_height` is one that MakerWorld projects routinely carry, so picking "0.08mm High Quality" for a file whose designer had moved layer height to 0.2 sliced at 0.2 while the dropdown still read 0.08 — the same 0.2 the settings panel showed, tagged "from file". Layer height and first layer height are now treated like the machine-coupled keys: still offered, never pre-selected, and their badge in the settings panel names the conflict and shows the preset's own value beside the file's, so ticking one is a deliberate choice. - **Statistics forgot the name of a printer that was deleted with its history kept (#2873, reported by @rembomy)** — Prints by Printer, the per-printer success breakdown, the time-accuracy list and Failures by Printer all resolved the name against the printers that exist right now, so deleting a printer and choosing to keep its prints turned "Ultron" into "Printer 1" everywhere. The runs themselves already recorded the name they printed on, and that is what those breakdowns fall back to now: the last name the id was known by, for as long as its prints are kept. A printer that still exists is named from its own record as before, so a rename shows up immediately rather than after the next print. Covered by backend and frontend regression tests. - **Skip Objects went dead for the rest of a print if Bambuddy restarted while it was running** — The object list lives in memory and is filled by the print-start path, which is deliberately suppressed on the first status push after a restart so the print is not archived twice. Everything else that moment restores — the archive, filament attribution, the timelapse baseline — came back; the object list did not, so the printer card saw zero objects and greyed out its Skip button. Measured on the maintainer's H2C: 8 objects loaded at 09:02, a restart at 09:17, and no way to skip anything for the remaining hour of the print. Nothing could recover it either, because the one endpoint that can rebuild the list is only reachable from the modal that the greyed-out button opens. The list is now restored on the way back up, from the archive of the print that is still running and matched on the job id the printer mints per print, so a stale archive cannot lend its objects to someone else's job. Two things behind it changed as well: rebuilding now reads the archived 3MF on disk before asking the printer for a file Bambuddy already has — that request was a full transfer off a machine mid-print, 15 MB in this case, and it cannot succeed at all on a printer that kept the file on internal storage — and the card now treats zero objects as "not loaded yet" rather than "nothing to skip", since a running print always has at least one. A single-object print still greys the button out, which is the case that rule was written for. The plate image in the modal came from the same place and had the same problem: the cover, the top view and the object-ID mask all re-fetched the 3MF from the printer after a restart, three fan-outs at once for one modal, so the picture arrived seconds after the list. They now read the running print's archived file too. Wiki updated. Covered by backend and frontend tests. - **A print archived without its 3MF can be given its filament weight by hand (#1820, reported by @ojimpo)** — When the sliced file stays somewhere Bambuddy cannot read, the archive is created from the printer's report alone and carries no weight, so the print is missing from every filament total and there was no way to put it right afterwards: Rescan reads the figure out of the 3MF, and that archive has no file to read. The reporter's H2S print left 46 g of PLA on the spool with nothing recording it, and he corrected Spoolman by hand. **Edit Archive** now has a **Filament used (g)** field. It is written to the print's most recent run as well as to the archive, because the Projects roll-up and the Prometheus counter sum the runs rather than the cards — correcting only the card would have fixed the display and left every aggregate reading the old figure. The value is bounded at 0 to 100 kg, it is sent only when you actually change it, so an ordinary save cannot round off a sliced figure, and emptying the field clears it. A run that measured its own weight through spool tracking keeps that measurement — the correction fills in a run that has none, or one that only ever inherited the archive's estimate, and never overwrites a real measurement with a typed one. Nothing is deducted from Spoolman or internal inventory either: those are charged from what was tracked at the time, and a print that recorded nothing has nothing to reverse. On an archive that does have its 3MF, Rescan still overwrites what you typed — there the file is the authority. Translated in all locales; wiki updated. Covered by backend and frontend tests. - **The internal-storage probe now logs which directory served the file (#1820)** — When a printer says a print went to internal storage and Bambuddy finds it over FTPS anyway (#2856), the log said which file but not where it came from. On a printer that keeps uploads for weeks — the reporter's H2S has months of them in `/cache` — a reprint of a name that was re-sliced but never re-sent can match an older copy, and without the directory in the log that mismatch was invisible rather than merely rare. The download helper now reports the path that served the file instead of a bare success flag, and the hit line names it. Covered by backend tests. - **Card and row actions were unreachable on phones and tablets (#2865, reported by @aishlai)** — The three-dot menu on a project card carries Edit and Delete and appeared only on hover, so on a phone there was no way to rename or delete a project at all; the File Manager's folder actions went the same way, as did duplicating a preset, renaming or deleting a tag, removing a print photo and deleting a plate-reference image. This is worse than a missing hover event: Tailwind v4 compiles `hover:` and `group-hover:` inside `@media (hover: hover)`, so on a touch-only device the rule that reveals the control is not merely never triggered, it is never applied — the element stays invisible for good. The hiding half is now what depends on a hover-capable pointer, so a device that cannot hover simply shows the control, and a mouse behaves exactly as before. Four spots on the Archives cards and two on the File Manager's file cards already tried to handle this by viewport width, under 768 pixels, which meant a phone was fine and a tablet in landscape was not; they now use the same capability check and the width guess is gone. Keyboard users were affected too, in the other direction — the buttons were invisible but still focusable, so tabbing through a card stopped on something nobody could see; focus now reveals them. Wiki updated. Covered by frontend tests. - **The File Manager's card menu no longer loses its top entry (#2846)** — In grid view a file card's action menu was drawn inside the card, and the card clipped anything its children painted outside it. A card is as tall as its square thumbnail plus whatever metadata the file has, so an STL — which has none beyond a name and a size — produced the shortest card in the library, about 270px against a seven-entry menu that needs closer to 310px. The difference was one row, and the row it took was the top one: **Slice**, since **Print** is only offered for a file that is already sliced. A 3MF carries a target model and a print count, two more rows, and its card was tall enough, which is why the button appeared there and looked like a file-type rule rather than a layout accident. Nothing about STL was special; the shortest card simply lost the first item, whichever it happened to be. The menu now opens against the viewport, the way the archive card's menu already did, so no card can crop it, and the card no longer clips its own children. List view was never affected — it has no menu, only inline buttons. Covered by a frontend test. - **Closing the bug-report panel no longer throws the capture away and leaves the logs running (#2847)** — Step 2 of the report flow asks you to reproduce the problem, and the panel sits over the part of the app you have to reach to do it. Closing it was the obvious move and it was the wrong one twice over. Reopening put you back on an empty step 1 — while the server was still logging at DEBUG, with nothing left in the flow that could stop it, because only **Stop & Submit** ever did. Leave it closed instead and the five-minute cap eventually fired behind your back: logging stopped and the report was filed with no window open and no confirmation that it had happened. Which of the two you got depended only on whether you reopened the panel inside five minutes. A capture is now a thing that outlives the panel. Closing keeps it running and says so — the bug button turns amber for as long as a capture is going, and clicking it returns you to step 2 with your description, your screenshot and the elapsed timer where you left them. If the cap does fire while the panel is closed, the panel reopens so the submission happens in front of you rather than behind you. The timer is measured against the capture's start time rather than counted in ticks, so a background tab, where browsers throttle timers hard, no longer stretches five minutes into something else. A capture also survives a page reload, which matters because reloading is a perfectly ordinary step in reproducing a bug: the report picks it back up where it was. One that outlived the cap while nobody was watching is not resumed and not filed — a description written an hour ago is not a report you are still expecting — but the log level is put back, which is the part that previously stayed wrong indefinitely. Translated in all locales; wiki updated. Covered by frontend tests. - **The build plate of a powered-down printer can be cleared again (#2864, reported by @bryanmahin)** — With Auto Power Off enabled this is the ordinary end of every print: the job finishes, Bambuddy switches the printer off at the plug, and the plate is left flagged dirty on a machine that is no longer reachable. The operator then walks over, clears the plate, and had no way to say so — `POST /printers/{id}/clear-plate` answered 400 "Printer not connected", and the button was hidden on the printer card, so the physical clear-plate buttons some farms drive over the API went dead too. Everything gated on the flag stayed stuck until each printer was powered back on by hand, cleared, and switched off again, which is the opposite of what Auto Power Off is for. Nothing in clearing a plate talks to the printer: the flag is Bambuddy's own state, persisted in its database precisely so it survives the power cycle, and the check that refused was inherited from the stop, pause and resume handlers next to it, where reaching the printer genuinely is required. It is gone, and the card offers the control whether the printer is online or not — in both card sizes, and for bulk selections, where powered-down printers were being filtered out of a Clear All. This does not dispatch work to an unreachable machine: the queue still requires a live connection before it sends anything, and releasing the gate is what lets it power the printer back on for the next job instead of skipping it. One related gap went with it — a printer with no live connection at all, disconnected by hand or not yet reconnected after a restart, reported its plate as clean over the API regardless of what the database said, which hid the control on exactly the printers that needed it. Wiki updated. Covered by backend and frontend tests. - **Timestamps hours ahead of themselves on PostgreSQL in a non-UTC zone (#2855, reporter @Tolga-Unal)** — On a UTC+3 install every AMS humidity reading and every archive was stamped three hours ahead of when it happened. Bambuddy stores naive timestamps holding UTC and the frontend reads an offsetless timestamp as UTC, so the display added the offset to a value that was already local. The Python side has honoured that contract since #504 — but the reporter's timestamps were not written by Python. Around ninety-six columns take their value from `server_default=func.now()` and the migration DDL carries another forty-nine on `DEFAULT CURRENT_TIMESTAMP`; the database fills those, and on PostgreSQL `now()` is a `timestamptz`, so storing it into a `timestamp without time zone` casts it through the session TimeZone. A Postgres container started with `TZ=Europe/Istanbul` bakes that zone in at initdb, and every defaulted column then receives local wall-clock. Connections now carry `timezone=UTC`, which makes the cast a no-op whatever the server is set to — measured through the real engine factory against a live PostgreSQL, a session on the reporter's configuration stored +10800s and the fixed one +0s. Pinning the session was preferred over a hundred and forty-five individual edits partly for its size but mostly because half of those sites are raw DDL no model-level change can reach. SQLite needed nothing: its `CURRENT_TIMESTAMP` is UTC by definition and it has no session timezone to get wrong, which is why this survived two years of timezone fixes without showing itself, and its behaviour is now pinned by a test rather than assumed. Rows already written are deliberately left alone — the inverse cast is computable, but `created_at` is assigned explicitly on some paths and defaulted on others, and an install that began on SQLite holds correct and shifted rows side by side with nothing to tell them apart. The support package's oldest-pending-job age went with it: it subtracted a naive local clock from a naive UTC column, reporting a job queued five minutes ago as three hours old east of Greenwich and a negative age west of it. - **A print with no 3MF borrowed another model's filament and cost (#2843, reporter @gyrene2083)** — An archive with no 3MF keeps the path the printer is executing as its filename, and on a sliced job that is always `Metadata/plate_1.gcode`. The fallback that looks for the same model in the Library or among earlier prints took its search term from there, so it searched for `plate_1` — a name every Bambu print in existence has — and matched on a substring, so it also matched any name merely ending that way. On an H2D a 1.6 g cube resolved to `lid_plate_1.gcode.3mf` and was costed at 207 g across three real spools; in the same database `Bank.3mf` matched "Piggo the piggy bank". The matcher now takes the model name the printer reports when the filename is only a plate path, refuses a bare plate stem rather than searching for it, and anchors to a whole filename with LIKE metacharacters escaped, because `_` is a wildcard and model names are full of them. A print that cannot be identified is left untracked, which is the honest answer; checked against every row on a real install, 233 archives still match their own filename and the fourteen results that changed are all false positives that went away. Two further faults went with it. Those archives could not receive a timelapse at all: the destination was derived from the missing file's path and landed one level outside the data directory, which in Docker meant every attempt failed EACCES and the scan re-downloaded and discarded the video twenty-five times over twelve minutes; it now uses the shared helper #1820 introduced for exactly this. And a slot whose spool has no RFID and no hand-set remaining amount was skipped silently when filament was charged by percentage delta, which is indistinguishable from having nothing to charge — it now says so in the log. - **A slot that could not be charged now says so (#2843)** — When a print's filament cannot be read from a 3MF, Bambuddy falls back to the drop in the AMS's own remaining-filament percentage. That needs a reading when the print starts, and a spool without RFID has none until you set a remaining amount by hand — so those slots were skipped in silence. Nothing was deducted and nothing said why, which is indistinguishable from having nothing to deduct. Every other reason for skipping a slot was already logged; this one now is too. - **Timelapses were lost, and written outside the data directory, for any print archived without a 3MF (#2843)** — Every H2-series and P2S print sent from Bambu Studio, so not a rare case. The video downloaded from the printer correctly and was then written next to the data directory rather than inside it, because an archive with no 3MF has no directory of its own and the destination was derived from the missing file's path. In Docker that meant a permission error, retried and discarded twenty-five times over twelve minutes, roughly a hundred connections to the printer for a video that was thrown away each round. Where that location happened to be writable it was worse: the file landed beside the installation, the attach failed anyway, and the stray video stayed there. Bambuddy has had a shared helper for exactly this since #1820 and this was the one place still deriving the path by hand. Timelapses now land in the archive's own folder and attach normally. Covered by backend tests. - **The connection diagnostic mistook a slicer's print for one of Bambuddy's own (#2843 follow-up)** — Bambuddy records the `project_file` command behind every print, because it names where the sliced file was put and so decides whether the archive can have a thumbnail and slicer metadata at all. Its own dispatches were told apart from a slicer's by testing the sequence id against 20000, on the belief that 20000 was Bambuddy's alone. It never was: 20000 is the slicer convention Bambuddy copied, and both slicers count up from it — measured on the wire, OrcaSlicer dispatched 20000 and then 20001, Bambu Studio 20009 and 20010. So whichever dispatch happened to land on the shared value was filed as ours and never recorded, and after a slicer restart that is the first print you send; the test was wrong in the other direction too, calling every higher value external including ones we had sent ourselves. Ownership is now established by remembering the job actually dispatched — sequence id, file, url and subtask name — and consuming that marker on the echo, one-shot, so a slicer reprint of the same file a moment later cannot hide behind our last one. Nothing about printing or archiving changes, and two tests pin that the storage verdict gating the FTPS sweep is untouched; what changes is that the diagnostic entry telling an operator whether their printer stores files where Bambuddy can read them stops undercounting, silently. - **A reprint of a file already on the printer lost its thumbnail (#2780 regression)** — A print of a file the printer already holds — a reprint from the touchscreen, from Handy, or a slicer send-to-storage followed by a print — reports its location as a path rather than as a fresh upload, `file:///media/usb0/`. Since #2780 landed Bambuddy read anything that was not `ftp://` as "the printer kept this internally", skipped the FTPS sweep, and archived the print with a name and timing only. Measured on an H2D: the file was listable and downloadable over FTPS at the moment Bambuddy declared it unreachable, and it was not confined to the H2 series the change was about — an X1C reprint from its own screen lost its thumbnail exactly the same way. That module's own rule is to skip only on positive evidence, and a `file://` path is not evidence of internal storage. It now reads the path: the printer's model cache under `/userdata` is a genuine skip, anything else is unknown and sweeps, which is what it did before. Unknown rather than external on purpose — the empty-slot check still runs ahead of it, so a `file://` print on a printer with nothing in the slot reports the missing card instead of sweeping for something that cannot be there. - **A printer that keeps its files on the card was written off as internal-storage-only (#2856, reporter @aishlai)** — A print's dispatch says where the printer put the sliced file: `ftp://` for external storage, `brtc://emmc/` for internal. Reading the second as "no file to fetch" is where the printer chose to put it, which is not the same as where port 990 can read it. The reporter's H2D — firmware 01.03.00.00, card in the slot — reports `brtc://emmc` and keeps the same file under `/cache`: their log has every print downloading from there, a 19 MB one included, until the skip landed and two days of archives came out as a name and nothing else. #2780's P2S and H2C really did fail on every path, so both are true and the URL alone cannot tell them apart. So Bambuddy now asks the printer rather than the model. The dispatch names the exact file, which turns the question into one connection walking five directories, against the sweep's ~110 that made skipping worth doing in the first place. A hit archives normally and is shared with the cover endpoint; a miss keeps #2780's fallback archive and its stated reason, so the banner still explains itself. It is not probed when the printer reports an empty slot, or while its file service is in TLS cool-off, because both have already answered the question. The connection diagnostic asked the same question off the URL and warned that the last print was out of reach — on this reporter's printer that warning pointed at a setting that was already right, so it now probes too, by directory listing rather than download, capped at six seconds to stay inside the support bundle's per-printer budget, with "could not check" leaving the warning standing. The probe filename arrives over MQTT and becomes both a remote path and a local temp filename, so names carrying separators, traversal or control characters are declined rather than cleaned. - **Energy tracking stopped as soon as a second plug was linked to a printer (#2859)** — Per-print energy is one plug's meter read at the start of a print and again at the end. Both readings asked for "the plug on this printer" in a way that raises when there are two rows, so linking a dry box, a filter fan or a lights script to a printer stopped energy tracking on that printer outright — and did it silently: the start handler logged the exception as an ordinary failure and the end handler then reported "no start kWh recorded", which is also what it says for a printer with no plug at all. The assumption was never enforced anywhere else; the plug API deliberately allows any number of Home Assistant entities, the unique constraint on the printer column was dropped on purpose, and every other consumer reads a list. Energy now ranks a printer's plugs — the one that powers it first, then by id so the start and end readings agree — and takes the first that actually reports a counter, so accessories drop out with nothing to configure. Ranking rather than filtering, because a printer whose only linked row is disabled or is a script used it before and still does; and when none of them measures anything, the log names the ones it tried instead of reading like "no plug configured". Existing archives cannot be backfilled, since the starting reading was never taken. Separately, the plug page counted an online plug as offline unless it reported energy, so a switch with no power sensor showed as offline for as long as it stayed linked. - **The smart plug page counted an online plug as offline unless it reported energy (#2859)** — A plug with no power sensor is still online, but "N/M plugs online" only counted the ones sending energy figures, so a working switch showed as offline for as long as it stayed linked. The count now reflects whether the plug answers. - **The AMS temperature alert fired for the whole of a drying cycle (#1802, reporter @apizz)** — The alert compares against the same threshold that colours the printer card, which defaults to 35 °C. Drying deliberately runs at 45 °C for PLA, 65 °C for PETG and up to 85 °C on an AMS-HT, and the alert repeats once an hour for as long as the condition holds, so a twelve-hour dry sent twelve notifications about a temperature the user chose — and then kept sending them while the unit cooled back down, which is the half the reporter confirmed on an AMS 2 Pro and an H2C. Dispatch now consults the drying state the firmware already reports. Dry time alone is not enough, since it reads zero through the cooling phase that closes a cycle, so the dry-status bits already parsed for the drying-complete edge carry the rest. The cool-down afterwards is held by a latch released as soon as the unit reads back at or below the threshold rather than after a fixed delay, so a 65 °C cycle in a cold basement and a 45 °C one in a warm room each get the time they actually need, with a two-hour cap bounding the one case the latch cannot resolve on its own. Two exclusions are deliberate: humidity is untouched, because during drying that reading falling is the whole point, and HeatOutOfControl is kept out of the active set, since an AMS that has lost thermal control is exactly when the alert should still arrive. A cycle plus its cool-down outlasts a restart, so the latch is stored rather than held in memory, with clocks that jump backwards clamped on read. No new setting: the reporter was offered the opt-out checkbox they asked for and said they would not want it if the alert simply never fired during drying. - **Automatic flow-dynamics calibration left an archive and two notifications behind** — With flow dynamics calibration on, the printer lays down a pressure-advance line before the print itself and announces it over MQTT through the same print-start event a real print uses. Bambuddy archived it: a row named `auto_pa_line_calib_mode` marked as having no 3MF, sitting in among the user's actual prints, with a Print Started and a Print Completed notification for each one. The printer's other internal jobs were already skipped, but only by the `/usr/` path they carry, and the pressure-advance line carries no path at all — it arrives as a bare subtask name, so a rule that only ever looked at the filename could not see it. Internal jobs are now recognised by name as well as by path, from either field, in one place both callbacks share, matching exactly after normalising away the directory, one print-file suffix and case: "auto" and "calib" are ordinary words in a user's own filenames, and a looser rule would quietly swallow somebody's print. Suppressing the completion matters more than the noise it removes — with no archive to close, an unmatched completion is attributed to any queue item the printer finished in the last five minutes and emails its owner, so silencing only the start would have told the owner of the real print running alongside it that their job was done, early, and again for real later. The guard sits inside that branch only, so the plate-clear gate, the queue reconciliation and the SD-card cleanup all still run. Skipping the run early also drops the FTP sweep that preceded the fallback: around a hundred connections looking for a file that cannot exist, aimed at a printer in the middle of calibrating. Bed levelling no longer sends a Print Started notification either. ### Security ## [1.2.5.3] - 2026-08-15 ### Added - **Billing and cost centres, with per-print charging and budgets (#1448, contributor @behrinml)** — Bambuddy could tell you what a print cost but could not hold anyone to it. There is now a finance layer behind the print flow: cost centres with budgets, per-user wallets, and a transaction for every print. A cost centre can be picked in the print dialog, travels with the queue item and the archive, and is reserved against before the job is dispatched rather than after it finishes, so a print that would take a budget past its limit does not start. Charges settle on real filament usage at completion, and a print that aborts part-way is charged for the part that ran instead of being written off or billed in full. Every user gets a personal cost centre and wallet on first sign-in, including the first sign-in through LDAP, so a directory-backed install does not need them created by hand. A monthly reset day and timezone decide when budgets roll over. The whole feature is behind a billing toggle and is off by default, and an optional printer kill switch stops dispatch entirely once a budget is exhausted. Cost-centre management has its own permissions rather than riding on the settings ones, so a farm can let someone spend against a budget without letting them change it. Ships with a Finance page, migrations for both SQLite and PostgreSQL, and translations in all locales. - **One queue item, several printer models — whichever frees up first (#671, reporter @brainomite; also delivers most of #2570, reporter @NeighborGeek)** — With an H2S and an H2C, a job you don't care which machine runs still had to be queued twice: the two printers need different slices, a queue item held exactly one file, and "any H2S" and "any H2C" were separate jobs competing for the same plastic. Whichever started first, you deleted the other by hand. Select both sliced files in the File Manager and press **Print** and you now get **one** queue item carrying both — the scheduler walks them in the order you arranged and takes the first whose model has an idle printer. The many-to-many never leaves the scheduler's selection loop: the moment a candidate wins, its file, plate and nozzle mapping are folded onto the queue row, so the upload, archive creation, print history and reprint all see an ordinary single-file job and behave exactly as they always have. Order is yours to set, because "both are free right now" has to resolve the same way every time rather than following whichever match the matcher happened to see first. Candidates are otherwise tried least-attempted first, so a printer that accepts the file and never starts hands the job to the other machine on the next lap instead of spending the item's whole retry budget on the one - **Billing and cost centres, with per-print charging and budgets (#1448, contributor @behrinml)** — Bambuddy could tell you what a print cost but could not hold anyone to it. There is now a finance layer behind the print flow: cost centres with budgets, per-user wallets, and a transaction for every print. A cost centre can be picked in the print dialog, travels with the queue item and the archive, and is reserved against before the job is dispatched rather than after it finishes, so a print that would take a budget past its limit does not start. Charges settle on real filament usage at completion, and a print that aborts part-way is charged for the part that ran insteadof being written off or billed in full. Every user gets a personal cost centre and wallet on first sign-in, including the first sign-in through LDAP, so a directory-backed install does not need them created by hand. A monthly reset day and timezone decide when budgets roll over. The whole feature is behind a billing toggle and is off by default, and an optional printer kill switch stops dispatch entirely once a budget is exhausted. Cost-centre management has its own permissions rather than riding on the settings ones, so a farm can let someone spend against a budget without letting them change it. Ships with a Finance page, migrations for both SQLite and PostgreSQL, and translations in all locales. - **One queue item, several printer models — whichever frees up first (#671, reporter @brainomite; also delivers most of #2570, reporter @NeighborGeek)** — With an H2S and an H2C, a job you don't care which machine runs still had to be queued twice: the two printers need different slices, a queue item held exactly one file, and "any H2S"and "any H2C" were separate jobs competing for the same plastic. Whichever started first, you deleted the other by hand. Select both sliced files in the File Manager andpress **Print** and you now get **one** queue item carrying both — the scheduler walks them in the order you arranged and takes the first whose model has an idle printer. The many-to-many never leaves the scheduler's selection loop: the moment a candidate wins, its file, plate and nozzle mapping are folded onto the queue row, so the upload, archive creation, print history and reprint all see an ordinary single-file job and behave exactly as they always have. Order is yours to set, because "both are free right now" has to resolve the same way every time rather than following whichever match the matcher happened to see first. Candidates are otherwise tried least-attempted first, so a printer that accepts the file and never starts hands the job to the other machine on the next lap instead of spending the item's whole retry budget on the onethat is wedged. The set is validated as a set: one file per printer model (two slices for the same machine are not alternatives, and picking between them arbitrarily would look like a bug the first time it chose your draft profile), every file gated against the model it is offered as, and at least one model that actually has a printer — grouping the H2C slice before the H2C arrives is fine, queueing a job nothing can ever run is not. A cross-model item deliberately holds no file of its own, so deleting one alternative leaves the job and its sibling intact; deleting or trashing every candidate holds it with an explanation instead of failing deep in the upload. Filament overrides offer everything loaded across **all** the candidate models rather than just the first — a spool loaded on only one of them is still a legitimate choice, it simply narrows which candidates can match — while AMS slot mapping is absent exactly as it is on an ordinary "Any [model]" job, because no printer has been picked yet and the scheduler derives the mapping against whichever one it takes. In the queue the job reads **Any H2D / X1C**, naming every model it is waiting on rather than filing itself under one it may never run on, and its waiting reason is given per model (`H2D: Busy: H2D-1; X1C: No matching material/color`), collapsing to a plain busy message — and no notification — when every model is merely printing. The alternatives are fixed once queued: the schedule, quantity and print options stay editable, but assigning a specific printer or narrowing to one model is refused by both the dialog and the API, since an item holding alternatives *and* a printer would dispatch down the fixed-printer path with no file to send. Cancel and re-queue to change the set. Files can also be grouped permanently with **Group as versions**, after which printing any one of them offers the others without re-selecting — this is the grouping and the print-time file matching asked for in #2570, minus its nested File Manager listing. An existing library arrives with its groups already built, from slice provenance Bambuddy has been recording since the Slice button shipped and had never read back. Translated in all locales; wiki updated. Covered by backend and frontend tests. - **Batch orders: a quantity per plate, and an order that knows what it still owes (#342, reporter @cimdDev)** — Printing a multi-plate file in different quantities per plate meant queueing each plate separately and tracking the counts yourself, because one shared **Quantity** field cannot say "plate 1 once, plate 2 twice, plate 3 three times". Each selected plate of a multi-plate file now carries its own quantity, and the submission becomes a **batch order** on a new **Batches** tab of the Print Queue page. What that buys is the distinction the old batch could not express: the order records how many runs of each plate were *wanted*, separately from what was queued. A run that fails, is cancelled or is skipped does not satisfy a target, so the order goes on saying it owes a print instead of quietly under-delivering — and a **Queue remaining** action re-queues exactly what is missing, for the whole order or one plate. Those new items are copied from the most recent run of that plate, so they inherit the printer or model target, AMS mapping, filament overrides and print options already chosen, and they are appended to the end of the relevant printer's queue rather than jumping ahead of work already lined up. Orders show progress against target, per-plate breakdown, and cost. Cost is measured rather than estimated: each finished run's material and energy are attributed through the queue item that produced them, so an unrelated reprint of the same file never lands in an order's total, and a multi-plate order gets each plate's own cost rather than the whole file's. Before any run has completed there is no honest figure, so cost reads as unknown instead of a fabricated `0.00`. An order becomes **completed** the moment its last run lands rather than whenever someone next opens the page, and raising a target on a finished order reopens it. Targets stay editable while the order runs, since production requirements change mid-job. The default flow is unchanged — creating an order still queues all of it immediately, and a single-plate file still has one Quantity field. Batches created before this release keep working and are labelled **Grouping only**: they only ever knew what was queued, not what was wanted, so they report progress but have nothing to dispatch. They also get closed out on the first start after upgrading — `completed` was not a reachable status before now, so every batch created since grouping shipped is still marked active however long ago its last print finished, and without that pass the new tab would open on months of accumulated history. Only batches with nothing queued or printing are touched: those whose runs all completed become completed, and groupings whose items were all cancelled become cancelled, which is what they are — calling them completed would claim output that never happened. Batches with neither queue items nor targets are no longer listed at all; those are empty shells left behind when a grouping's items were deleted with their source archive. Translated in all locales; wiki updated. Covered by backend and frontend tests. - **Nest projects under a master project and roll their figures up (#1264)** — Projects were flat. The `parent_id` column and the sub-project list already existed but nothing outside the API could set a parent, and a master project's statistics only ever covered its own prints. The project dialog now has a parent picker, and a project with sub-projects gets a second card covering the whole tree: jobs, parts, time, filament, cost, and progress against every target in the tree added together. That card is deliberately separate from the project's own stats, which keep their existing meaning — widening them would have restated the figures of anyone who had already nested projects over the API. Each listed sub-project carries its own branch's roll-up, so the rows add up to the card above them. On the Projects page a sub-project is drawn inside its parent's group rather than as another card in the grid, because two cards columns apart cannot show that they belong together whatever the caption says. Translated in all locales; wiki updated. - **Keep the chamber warm between prints and skip a soak that is not needed (#2727, contributor @ticfinack)** — Back-to-back prints in chamber-heated materials — ASA, ABS, PA, PC — each paid a full heat soak from cold, even when the print that just finished had left the chamber at temperature. Two changes remove that cost. While a printer sits in FINISH waiting for plate-clear and the next queued item needs chamber heat, the bed is held hot so the chamber does not cool during the bed-clearing window. The bed is the chamber's heating element here rather than a print surface, so the hold runs at the new **Keep-warm bed temperature** (90 °C by default, which also satisfies the aftermarket chamber heaters that trigger off a bed threshold) and rises to the item's own bed temperature when that is higher. It is gated on the keep-warm setting, on plate-clear being required, and on the next item actually needing the heat, and it is capped by a maximum duration so a queue that stalls does not leave a bed hot indefinitely. A follow-up closed the two dispatch exits that could drop the hold without releasing it — a claim failure returns before the rollback opens, and a vanished row left the printer id unset, which the rollback guards on — either of which left a bed hot with nothing tracking it, reachable whenever a cancel or delete landed between selection and the claim. - **Restore selected categories from a Git backup commit (#2656)** — Bambuddy has pushed backups to GitHub, GitLab, Gitea and Forgejo for a long time, and every one of those commits was a restore point that nothing could read back. Recovering from a bad settings change, a rebuilt instance or a lost database meant opening the repository by hand and copying JSON into the right places, if you knew which places those were. **Settings → Backup & Restore → Restore from Git** now picks any of the twenty most recent commits and pulls back the categories you tick — K-profiles, app settings, spool inventory and print history — without touching anything you did not select. The modal previews the commit before anything is written: it shows how many items each category holds and greys out the ones that commit does not contain, so a category you only enabled last week is visibly absent from older commits rather than silently restoring nothing. **Overwrite existing entries** decides what happens when something already exists locally — off, it fills in what is missing and leaves the rest alone; on, it makes the local row match the backup. The result panel reports what actually happened per category as restored, skipped and failed, and those three always add up to the number the preview showed you, so a count that does not match the preview is a bug rather than something to interpret. Restoring never resurrects a credential: the backup carries MQTT, LDAP, Home Assistant and Prometheus secrets so that a repository is a complete record, but the restore refuses every one of them, and refuses along with them any switch that would be left pointing at a service it can no longer authenticate to — an exposed Prometheus endpoint with no token is worse than one that stays off. The keys that decide who can reach the instance at all are refused outright for the same reason — the four authentication-policy switches, and the whole LDAP family alongside them, since those name *which directory server decides who you are* rather than how the instance behaves. Authentication is reconfigured through the auth UI, which has the guards that a JSON file does not. Print archives come back as history only, since a Git backup holds metadata and never the 3MF or thumbnail bytes, and each one is returned to its owner by username rather than by user id — an id means nothing on a rebuilt instance, where it would hand one person's print history to whoever now holds that number. An archive whose owner this instance does not have lands unowned with a note saying so, rather than being attributed to a stranger; one that already exists locally keeps the owner it already has, because an owner the backup cannot name is not an instruction to take one away. K-profiles are the one category that leaves the database: they are sent to the printer over MQTT, which means the printer must be online, and writing a slot is always an overwrite there regardless of the toggle — the modal says so before you click rather than in the summary afterwards. Cloud profiles are backed up but deliberately not restorable, as writing them means writing to a Bambu or Orca account rather than to this instance. **Restoring is permissioned per category**: `github:restore` opens the dialog, and each category additionally requires the permission that owns its rows — `settings:update`, `inventory:update`, `archives:update_all` and `kprofiles:update` — so a role cannot write through a restore what it cannot write through the page that owns it. Administrators hold all of them already; a custom role built around the Backup permissions alone can open the dialog and preview a commit, but needs the owning permission for each category you want it to be able to write. Each category is committed as it completes rather than at the end, so a large restore does not hold the database against the rest of Bambuddy for the length of the run; the trade is that a failure part-way through leaves the categories that already finished in place, which the result panel reports rather than claiming nothing was restored. Translated in all locales; wiki updated. Covered by backend and frontend tests. - **Edit the full print-parameter set from the slice dialog** — Slicing from Bambuddy meant taking a process preset as it came; any change meant a round trip through Bambu Studio. The slice dialog now carries OrcaSlicer's full process tree — pages, groups, labels, tooltips, ranges and defaults, extracted from the slicer's own sources rather than restated by hand. Which fields are available depends on the others, and those rules are evaluated from the slicer's own `enable_if` expressions through a small recursive-descent interpreter — no `eval`, so it runs under the Content Security Policy — with enum comparisons checked against each option's declared values. Anything the interpreter cannot decide is left editable rather than greyed out, on the principle that a field you can change is a better failure than one you cannot. Overrides apply after the source's support configuration (#1881) and the designer's carried tweaks (#2622), so an explicit choice always wins, and an untouched panel sends exactly the request it sent before. Adds `slice_engine` as a setting separate from `preferred_slicer`, since where slicing runs is a different question from which binary drives it. - **A new G-code and model preview, built on the slicer's own renderer** — Sliced files previewed through a vendored copy of PrettyGCode in an iframe, which drew each move as a screen-space line. A line has no thickness in the scene, so it cannot occlude the layer behind it — which is why prints came out stringy and shimmered wherever layers crossed. Being a separate application in a frame, it could be neither themed nor translated, and it carried its own machinery for noticing when a proxy refused the embed. The viewer is now built on libvgcode, the renderer OrcaSlicer draws its own preview with, vendored from three-slicer under the same AGPL licence we ship under. The parser is ours, because upstream renders its own kernel's output and ships no G-code parser at all, and it was written against a real BambuStudio plate rather than against the OrcaSlicer and PrusaSlicer annotations that file does not use. The model preview was rebuilt alongside it: the camera is solved from the subject's bounding sphere against both fields of view instead of a flat multiplier, so a model fills a tall narrow panel instead of floating in the middle of it, and lighting moved from two directional lamps over flat ambient on a Phong material to a MeshStandard material lit through a generated room environment with ACES tone mapping — which is what stops a saturated filament colour clipping to white on its lit side and draining the hue. Translated in all locales. - **Choose which rack nozzle each filament prints from on an H2C (#1784)** — The Vortek rack holds six hotends, and a multi-colour plate is sliced to use a different one per colour so it can skip the purge. Which of the six each colour takes is not recorded in the 3MF: the same plate, sliced and sent twice from Bambu Studio with a different choice each time, produces two files that differ only in rounding in the last digit of a few extrusion figures. The filament grouping, the toolchange stream, the 120 nozzle-change markers and `project_settings.config` are identical. The choice travels only in the dispatched nozzle mapping, and Bambuddy had no way to state it, so those plates went out with no nozzle assignment at all and the printer chose for itself — which is what levelled on one hotend and printed with another, millimetres above the plate. Every rack-bound filament now carries a position picker beside its AMS slot dropdown, listing all six with the nozzle each holds. A position that is empty, or holds the wrong diameter or flow type, is shown greyed out with the reason rather than hidden, so someone looking for position 4 finds it. The choice is per filament *group* rather than per slot, because a group is one hotend: two filaments the slicer grouped together share it and cannot point at different positions. Translated in all locales; wiki updated. - **Home Assistant sensors on the printer card, with an optional print interlock (#1148, reporter @bsaunder; #448, reporter @baudneo)** — Bambuddy could already switch a Home Assistant entity as a smart plug, but it had no way to *read* one. A printer in a home-built enclosure with a door contact, or an A1 with an aftermarket chamber thermometer, had all that data in Home Assistant and none of it in Bambuddy — the reporter's actual problem being that he could not tell whether he had left the enclosure open before starting a print from his phone. **Settings → Smart Plugs → Home Assistant Sensors** now binds any `binary_sensor` — a door, window, smoke or moisture contact — or any `sensor` that carries a reading to a printer, and its state appears on that printer's card. The wording follows Home Assistant's own device class, so a door reads Open or Closed rather than On or Off, and a thermometer reads `41.2 °C`; entities with no device class fall back to on/off, exactly as Home Assistant shows them. A sensor can be given an alert condition — on, off, above a value, below a value — which highlights it on the card and unlocks two things it would otherwise be pointless to offer. **Notify on alert** sends a notification the moment the sensor enters that state, once on the way in rather than on every poll, and not again when a flaky contact drops off the network and comes back still alerting. **Hold prints while alerting** is the part that answers the original question without anyone having to look: queued jobs for that printer wait, with a reason on the Queue page you can read at a glance ("Waiting on Enclosure Door"), and start by themselves as soon as the door shuts. Nothing is ever cancelled, and a job queued as "Any X1C" simply goes to a sibling whose sensors are clear instead of waiting behind the one that is held. The interlock is deliberately one-directional: it holds only on a sensor that was read successfully and *is* alerting, so a Home Assistant that is unreachable holds nothing and the queue keeps running as though no interlock were configured. Sensors are their own thing rather than a smart plug with a wider entity filter — a plug carries auto-on, schedules, power alerts and "controls printer power", and the printer card's power button would have happily tried to switch a door contact. One backend poller reads every bound entity every 15 seconds and the cards serve that cached reading, so the cost to Home Assistant does not grow with the number of printers on screen or browser tabs open. Off by default in every respect: a newly bound sensor is display-only until you give it an alert condition, and both the notification and the interlock are separate opt-ins on top of that. - **The Print Log shows how much filament a run used, and lets you choose its columns (#2636, reporter @ajbastien)** — The log view listed which filament a print used — a colour dot and "PLA" — but never how much, which is the number you actually want when reading back a month of prints. The amount was already on every row the API returned; the table was simply hardcoded to seven columns. **Filament Used** now has a column of its own, on by default. **Cost**, **Energy**, **Energy Cost** and **Finished** join it as columns you can switch on via a new **Columns** picker above the table, which also reorders them by drag or arrow keys and remembers the layout per browser. Every column header now sorts, too — click to sort, click again to reverse, with dates and amounts opening largest-first and text A-Z. The sort runs over the whole log rather than the page on screen, since a table that pages server-side would otherwise answer "the most expensive print among these 25"; changing it returns you to page 1, and rows with nothing in the sorted column are held at the end in both directions so sorting by cost or energy never opens on a screenful of blanks. That last part is explicit rather than left to the database: Postgres sorts empties high and SQLite sorts them low, so the same click would otherwise land differently depending on which one you deployed. These are per-run figures rather than the file's estimate: a print that failed part-way is scaled to the progress it reached, tracked spools win over estimates, and a multi-plate project dispatched a plate at a time counts only the plate that ran — so a row can legitimately differ from the whole-file total on the archive card. Energy arrives from a background task a moment after the print ends, so a just-finished run shows a dash until the measurement lands, which is the honest answer rather than a zero. Note the per-archive **Print Log** button opens a different, smaller table that has shown grams all along; this brings the two into agreement. Translated in all locales; wiki updated. Covered by frontend tests. - **Auto-orient and auto-arrange when slicing server-side (#2548, reporter @ceokingcobra)** — A slice in Bambuddy always kept the placement the file arrived with, so several parts dropped onto one plate could come back overlapping, and a model resting on a face that needs supports stayed on it. Bambu Studio's two layout buttons simply had no counterpart here. Two checkboxes below the build-plate override now run the same passes before the slice: **Auto-orient objects** turns each object onto the side that prints best — the slicer scores candidate rotations on overhang area, contour and unprintability, and a test part here came out 8% faster with nothing else changed — and **Auto-arrange on the plate** lays the objects out so they no longer overlap, which is also the fix for a source file whose coordinates fall off the edge of a smaller target bed. Both are per-slice and off by default, and neither is stored on a preset or a pipeline: they rewrite placement an author may have chosen deliberately, so they are something you ask for rather than something that happens to you. They stay available on the "Slice as designed" path, unlike the preset dropdowns and bed type — these act on the geometry rather than the print config, so where the settings came from is irrelevant to them — and they carry across the automatic retry that falls back to a file's embedded settings after a slicer crash, which would otherwise hand back an un-arranged result you had in fact asked for. Arrange is project-wide inside the slicer, so pairing it with **Slice all plates** would collapse every plate's objects onto a single bed; Bambuddy takes the same per-plate loop it already used for cross-class re-slices and merges the outputs, since that hazard belongs to the flag and not to the one case that first ran into it — including on the "Slice as designed" path, and with the crash-retry suppressed there, because a retry is a single call that would have handed back one consolidated plate for a job you asked to slice as several. Orient needs no such handling — it rotates objects where they stand and never moves one between plates. A cross-class re-slice still arranges whether or not the box is ticked, because that pass is what keeps it clear of the H2D's per-nozzle dead zones. An unticked box is sent by leaving the field off the request entirely: the sidecar treats any value that is present as true, so a literal "false" would have auto-arranged every slice in Bambuddy. Translated in all locales; wiki updated. Covered by backend and frontend tests. - **Open a File Manager model in your desktop slicer, and pick which one from the 3D preview (#2725, contributor @pascalheidmann)** — The **Slice** action on a file card only existed when the optional slicer sidecar was running. Turn the sidecar off — which is the default, and how most installs run — and the File Manager offered no way to get a model into a slicer at all, even though the Archives page has handed files to a locally-installed Bambu Studio or OrcaSlicer over the URI scheme for a long time. The File Manager was simply the one place that never got it. **Slice** now appears on every unsliced model (`.3mf`, `.stl`, `.step`, `.stp`) in both the card menu and the list view, and does whichever of the two things your configuration means: with the sidecar on it opens Bambuddy's slice modal and the work happens on the server, and with it off it hands the file to your desktop slicer. The icon says which you will get before you click — a cog for server-side slicing, an external-link arrow for the handoff — and which slicer receives the handoff comes from **Settings → Workflow → Slicer → Open in Slicer**, falling back to the preferred slicer as it always has. The 3D preview goes further, because that is where you are actually looking at the model and deciding: its slicer button is now a split button, and the chevron beside it offers the alternatives without changing any setting. With the sidecar off that is the slicer you did *not* pick as your desktop target; with it on, the primary button still slices server-side and the menu offers a one-off desktop handoff to either slicer. Two things that used to be silent now are not: a handoff refused for want of permission raises an error toast rather than launching the slicer at a URL it cannot fetch, where a permission problem looked exactly like "no slicer installed"; and the file-type rule is shared between the card menu and the 3D preview, so a file can no longer offer **Slice** in one place while showing it greyed out in the other. Permissions follow the endpoint each mode calls — the handoff is a download and needs the same library read permission that lets you see the file, server-side slicing writes a new file and needs upload rights — and the action is shown disabled with the missing permission named rather than hidden. Translated in all locales; wiki updated. Covered by frontend tests. - **Server-side slicing on an ARM64 host (#1900, contributor Felix Reissmann)** — The slicer sidecar ships as an amd64 image, so ARM64 installs had no server-side slicing at all and the compose file's own header said so. There is now an override file that pins the sidecar's platform to `linux/amd64` and runs it under emulation, with the setup written where it is read rather than a hop away in the wiki: binfmt has to be registered on the host or the container dies with "exec format error", and emulation costs roughly three to six times native slice time. The override only applies while both `-f` flags are on the command line and every other instruction in the README is written bare, so an ARM64 user following the update steps would have dropped the platform pin without noticing — a manifest error today, and a silent switch off emulation once native ARM64 images ship. The quick start now writes `COMPOSE_FILE` into `.env` instead, so the rest of the file works unchanged on ARM64; verified both ways. A separate x86_64 machine stays the recommendation — emulation is the fallback for people who have no second machine, not a replacement for one. - **Temperatures on the streaming overlay, and a builder for its URL (#1422, reporter @SMAW)** — The overlay at `/overlay/{printer}` draws live print data over a full-screen camera view for OBS, a wall display or any browser source. It could already be tuned — which fields, what size, what frame rate — but only through query parameters documented in the wiki, and temperatures were not among the fields on offer. Both are now addressed. Nozzle, bed and chamber readings join the list, shown with the target while the heater is still climbing and with the target dropped once it is reached, so a settled hotend reads "220°C" rather than "220 / 220°C" for the rest of the print. Both nozzles appear on a dual-nozzle printer. They are drawn whether or not a print is running, since a preheating machine is exactly when they are worth watching, and each reading appears only when the printer genuinely reports it — chamber temperature stays absent on P1 and A1 models, which publish a value with no sensor behind it. And **Settings → API Keys → Streaming Overlay** now builds the URL for you: pick the printer, tick the fields, set size and frame rate, paste in a token if login is enabled, and copy the result, with an optional preview alongside it. The preview stays off until you ask for it so that leaving the settings page open does not hold a viewer on the printer's single camera connection. Making that preview possible needed one narrow change to the security headers: the overlay path now sends `frame-ancestors 'self'` instead of `'none'`, so Bambuddy's own UI can embed it. Every other page still refuses to be framed at all, `'self'` permits a framer only on this same origin, and embedding the overlay from another host — Home Assistant on a different port, say — is unchanged and still requires `TRUSTED_FRAME_ORIGINS`. Temperatures are not in the default field set, so an overlay URL already pasted into a scene looks exactly the same after upgrading. Translated in all locales, wiki updated, covered by backend and frontend tests. - **Open a multi-plate sliced file on the plate you asked for** — Previewing a sliced multi-plate 3MF from the File Manager showed a plate nobody picked. The library route took no plate parameter at all, so the one the viewer has always put in the URL was dropped — FastAPI discards unknown query parameters silently — and both routes then fell back to the first `.gcode` member of the zip, whose order is whatever the slicer wrote. The reported file stores `plate_2.gcode` ahead of `plate_1.gcode`. Nothing that opens the viewer from the File Manager passed a plate either, so there was no way to ask for a different one. Plate resolution now lives in one place and both routes share it: an explicit choice that does not exist returns a 404 rather than rendering something else, and the default is the lowest-numbered plate rather than the first one in the archive. The viewer gained a plate switcher and keeps the choice in its URL, so a link to one plate survives a reload, and filament colours follow the switcher — they were previously taken from the first plate whichever one was on screen. - **Show the plug that actually powers the printer in the card's Power row (#2830)** — A printer card has one Power row: a plug name, its draw, and the auto-off and on/off buttons. Which plug filled it was decided by nothing at all — the endpoint returned the first row the database handed back that was not a Home Assistant script, from a query with no `ORDER BY`. For the reporter that was an enclosure exhaust fan, added before the outlet their X1C is plugged into. The card showed the fan's name with `--` for watts, offered to switch the printer off by cutting the fan, and demoted the metered outlet to the small Home Assistant button row. The fan was marked as not powering the printer and hidden from the card, and neither setting was consulted here, though the same flag has decided the scheduler's power-on pick since #2629. The candidates are now ranked: switchable at all, controls printer power, enabled, shown on the card, reports power, lowest id. The first rules out a script, which can only be run, and a monitor-only MQTT plug, which the control endpoint rejects — and an MQTT plug is exactly the kind that reports watts, so without that ahead of the power tiebreak the row could land on a plug whose on/off button answers with an error. The last is not cosmetic: with no `ORDER BY`, a plain `UPDATE` on PostgreSQL can move a row and silently swap which plug the card calls the printer's power. None of the criteria excludes a plug outright, because a printer whose only plug is hidden, disabled or monitor-only still needs its Power row — that row is where the on/off button and the Home Assistant buttons live. - **The Printers page remembers its status and location filters (#2833)** — Pick a location, navigate away, come back, and every printer was showing again. Both filters were plain component state, and they were the only preferences on that page that were not remembered: sort order, card size, view mode, collapsed sections and hide-disconnected all persist, each with the same shape. These two now do too. A saved filter needs a way out, though. The location dropdown only renders while at least one printer has a location, so a saved location that was later renamed or removed would match nothing and take its own dropdown off screen with it — an empty page and no control to undo it. A location that is not among the available ones now resets to all, and the same for a status the dropdown does not offer. That check waits for the printers query to resolve, because the list is undefined while it is in flight and acting on that would throw the saved filter away on every page load. Search stays unpersisted on purpose: a box that silently refills itself on return is a surprise rather than a convenience. - **The external spool can be hidden from the printer card (#1782, reporter @Arn0uDz)** — An external spool holder that never gets used still occupies a full card's width in the **Filaments** row, next to the AMS units that are actually being used. An eye icon at the right-hand end of that row's header now hides it, and clicking it again brings it back, so nothing is lost behind a settings page you would have to remember. The choice is remembered per printer and stored in the browser, like the card size and the offline-printer filter — one machine in a fleet can be tidied up without touching the others, and nothing changes for anyone else using the same Bambuddy. The icon is deliberately absent on a printer with no AMS: there the external spool is the entire filament section, and hiding it would leave an empty row. That guard also covers the case of an AMS being unplugged from a printer whose external spool was hidden earlier — the spool reappears rather than leaving a blank row behind. On the H2D and H2S both external positions share one card and so hide together. Translated in all locales, wiki updated, covered by frontend tests. - **Uploaded archives can be named after the filename you sent (#2610, contributor @Person2099)** — A 3MF carries a `print_name` inside its metadata, and that is the name an archive gets. It is often the wrong one: whoever originally sliced the file baked their own title into it, and that title survives every rename afterwards, so a file you deliberately named after the job it belongs to still lands in the archive list under a stranger's label. Bambuddy could already prefer the filename — the FTP review flow and virtual-printer dispatch both do, driven by the virtual printer's **Archive name source** setting — but the upload endpoints could not, so anything sent through the API was stuck with the embedded title. `POST /archives/upload` and `POST /archives/upload-bulk` now take an optional `prefer_filename_for_name` query parameter that names the archive after the uploaded file instead; on the bulk route it applies to every file in the batch. It is off by default, so nothing changes for existing callers or for the Web UI's own upload, which does not set it. A per-request parameter rather than a setting, because the caller — typically an integration naming files after its own jobs — is the only party that knows whether the filename it sent is the meaningful one. Both parameters are documented in the OpenAPI schema, so they show up in `/docs`. Covered by backend tests. - **API clients can resolve user ids to names (#1894)** — Archives, the queue and statistics all report ownership as a numeric id, and statistics accept it as a filter, but nothing let an API key discover whose id was whose. The only user listing returns emails, roles, group membership and full permission sets, so it is administrative and rejects keys outright. There is now a slim user listing returning id and username only, behind its own read permission mapped to status-read. It grants no data a key could not already reach: for API-keyed requests the filter-by-user guard already short-circuits and the created-by filter is already honoured for every id — what was missing was the ability to address the filter, not permission to use it. The full listing stays admin-only. `/auth/me` was fixed alongside it, having answered an API key with a synthetic record rather than the key owner's own. - **Record who queued a file from the Library and through the webhook API** — The queue's own-work permissions filter on who created an item, but only three of the paths that create queue items were setting it. The Library's bulk **Add to queue** required the create permission and then discarded the user it had just resolved, so every item it made was ownerless — and invisible to the person who added it if their permissions are scoped to their own work. That is the one path built for adding many files at once, which is where it was hardest to notice. The webhook endpoint has no request user, but an API key records its owner, which is the acting identity everywhere else a key is used, so its items are credited to that owner. Keys minted before per-user ownership have no owner and their items stay ownerless. - **Forgejo tokens scoped to a single repository are accepted (#2775)** — The Forgejo connection test asked who the token belonged to before asking whether it could reach the repository, and treated a 403 there as fatal. A Forgejo v15 repository-scoped token may carry only read or write on issues and repositories, so it fails that call — and was rejected despite reaching its own repository perfectly well, which is all a backup needs: the push path uses the Contents API and restore reads commits, trees and blobs, all under the repository itself. That was the only call in the whole provider layer that asked about the user. The probe stays, because a 401 from it is genuinely conclusive and names a bad token before the repository call has to guess — Forgejo v15 and later hide a private repository behind a 404 rather than a 403, so the repository call cannot always tell those apart. Every other status now falls through to the repository check. - **The MQTT debug log now records the commands sent to a printer, not only what it reports back** — **Printer → Debug → MQTT** captured one side of the conversation. Bambuddy listens on both of a printer's topics, but the one carrying commands returned before anything was written to the log, so a capture could show every status push the printer made and nothing it was ever told — including the commands Bambu Studio sends over the local network, which is the only place they can be observed at all. Those now appear alongside Bambuddy's own, grouped under the outgoing filter. It is what lets a question like "which value does Studio put in this field?" be answered from a user's capture instead of guessed at, and it is why #2774 could not be taken further. Commands Bambuddy sends appear twice, once as it publishes and once as the broker echoes it back, and the pair is itself evidence the command reached the broker. Logging is off until switched on, as before. Covered by backend tests. ### Changed - **The L and XL printer cards now scale their text and icons, not just their width (#1848, reporter @misterff1)** — Switching a card from M to XL made it wider, enlarged the printer name and the thumbnail, and left everything else exactly as it was: the AMS slot labels, temperatures, filament names, status text and every small button stayed pinned between 8 and 11 pixels, well under the smallest size used anywhere else in Bambuddy. The result was a full-width card carrying the same tiny text as the compact one, which is precisely the opposite of what someone reaching for a bigger card is asking for. Browser zoom is not an answer to this, since it enlarges the entire page and so preserves the very disparity being complained about. The card body now scales along with the card: L draws it 20% larger and XL 40% larger, icons included, so the controls grow with the text rather than staying fiddly to hit. The AMS-HT card needed two adjustments of its own, since its temperature and humidity readings sit beside the slot rather than under it. Its single slot was the only thing on that row able to grow, so it swallowed every spare pixel and pushed the readings hard against the card's edge — it is now capped at roughly two ordinary slots, which keeps them clear at any card width. The card itself also gained a ceiling of one full AMS card's width, so a unit that wraps onto a line of its own no longer stretches that single slot across the whole card. S and M are deliberately untouched — S is the dense fleet view where density is the point, and M is the default, so an existing install looks identical until you reach for a size that is already asking for more room. Wiki updated. Covered by frontend tests. - **The H2C nozzle rack card sizes itself to its contents and numbers its slots** — The rack card shared a row with the nozzle, bed and chamber readings but was given a 190-pixel floor plus twice their growth share to draw six fixed 28-pixel chips. On anything wider than a compact card it claimed several hundred pixels and left most of them empty, and the width came out of the cards that needed it — a combined dual-nozzle reading was wrapping "220 / 220" onto two lines beside a mostly blank rack. It now takes its content width and gives the remainder back, while still giving way on a narrow card rather than overflowing. Each slot also carries its physical rack position, 1 to 6, below the chip, so a nozzle can be named rather than counted along; the numbering is positional, so an empty slot keeps its number and "the nozzle in slot 4" means the same thing however many of the six are occupied. The chips also scale with the card now: they were a hard-coded 28 pixels at every size while the body type and icons around them grow by 20% at L and 40% at XL, so setting the card larger left a shrunken strip beside neighbours that had grown around it, with the diameter figures pressing against chip edges that had not moved. - **The process-settings panel shows the preset's own values, and says why when it cannot** — The panel baselined every field on the option schema's compiled-in defaults, so a preset setting a 0.42 mm line width displayed 0 — the C++ default meaning "derive from the nozzle". Every field was affected; the line-width group just made it obvious. Bambuddy cannot answer this on its own: a standard-tier pick is only an `inherits` stub on our side, and local or cloud presets are deltas whose remainder lives in the profile tree bundled inside the running sidecar. The values now come from the sidecar's own resolve endpoint, which runs the same resolver a slice does against the same profiles, so what the panel shows cannot disagree with what a slice produces. Deliberately not the local profile resolver, which walks OrcaSlicer's published tree and can differ from the image actually installed. An untouched field shows the preset's value and reverting returns to it, and the modified marker compares against that baseline too, so fields the preset moved off the C++ default are no longer flagged as user edits. When the values cannot be read, the panel now names which of the four causes applied instead of collapsing them into one message. The overwhelmingly common one has an obvious fix and is not an edge case: an install pulls its sidecar as `latest` whatever channel Bambuddy is on, so a current Bambuddy talking to a sidecar that predates the resolve endpoint is the normal state rather than a misconfiguration, and those users would otherwise have seen an amber warning on every slice with nothing pointing at the sidecar image. - **Pool AMS Filament Backup spools in the print dialog's filament check** — The dialog weighed each plate against the spool in the slot it mapped to and knew nothing about AMS Filament Backup, so a two-plate job needing 1441 g of ABS was refused against a 1000 g spool while the identical full spool in the next slot went uncounted. The dispatcher has pooled matching spools since #1762 and would have run the print — "Print anyway" was always the right answer to this warning. The rule for which spools back each other up now lives in one place that both the dispatcher's pool and the dialog's check draw on. The dialog groups on the keys it is handed rather than resolving spools a second time, which is what let the two answers drift apart in the first place, and which also gives the check to Spoolman users. - **API keys can read and run slicer pipelines (#1425)** — Every pipeline endpoint answered 403 to an API key whatever scopes the key carried. The first pipeline PR parked all three permissions on the admin denylist until the run dispatch existed to decide about; it landed later and the parking was never revisited. Pipeline read now rides status-read, and pipeline run requires queue and library-manage together — a run slices into the library and then queues prints, so mapping it to either flag alone would hand that flag the other one's authority. A 403 now names every flag the key is short of. Pipeline write stays admin-only: a key can run the recipe, not rewrite it or clear the log. Opening the run route also needed the cloud-owner fallback the direct slice route already makes, since a pipeline can carry Bambu or Orca Cloud presets and resolving those reads a token off a user record an API-keyed request does not have. - **Sort the inventory by colour (#2729, reporter @macwhiz)** — The Color column showed a swatch on every row but ignored clicks on its header, so the only way to find a shade was to sort by colour *name* — and filament names don't sort into anything useful, with "Abyssal Purple" landing at the top of the list nowhere near "Purple". The column sorts now, by the colour itself: ascending walks the rainbow, then the browns, then the neutrals from white through the greys to black, with any spool that has no colour recorded held at the end. The issue asked for a straight hue-then-saturation-then-lightness sort, and that turned out not to survive a real inventory — a grey is only a shade off neutral but still has a hue, and that hue can be anything. Measured against a 30-spool inventory, it put Titan Gray in among the blues and a warm grey next to the reds, while brown, being a dark orange by hue, split the oranges in half. So the colour family leads and the continuous sort the issue asked for runs inside each family, using the same classification that already names a colour when it isn't in the colour catalog — which means the Color column and the Color Name column can never disagree about what counts as brown or grey. Within the neutrals, hue is discarded rather than sorted on, for the same reason it isn't trusted to pick the family in the first place: they run light to dark instead. Multi-colour spools sort by their primary colour, since a spool has to sit in exactly one place. Works the same for internal and Spoolman-backed inventories, and in both the table and card views. Wiki updated. Covered by frontend tests. - **Chamber temperature can now be set up to 65 °C, not 60 (reported on Discord)** — Every field in Bambuddy that takes a chamber target stopped at 60 °C: the per-filament chamber map and the per-print chamber override in **Preheat & Heat Soak**, the chamber quick-select presets, and the chamber temperature control on the printer card. 60 is the ceiling for the X1E, which was the only heated-chamber model when that limit was written; the H2 series (H2C, H2D, H2D Pro, H2S) and the X2D heat to 65, so the top of their range was simply unreachable — an ABS or PA profile calling for 65 had to be run at 60. The ceiling is now 65 everywhere, held in one constant on each side rather than repeated as a literal at every call site, so the four surfaces cannot drift apart again. X1E owners are unaffected: its firmware clamps a higher request to its own maximum. Wiki updated. Covered by backend tests. - **Error and warning toasts now stay up twice as long** — Every pop-up notification disappeared after three seconds regardless of what it said. That is about right for "Settings saved", which confirms something you just did and is skimmed rather than read, but errors and warnings are a different kind of message: they carry a reason, often one relayed from the printer or the backend, and they run to a couple of lines. Three seconds was not long enough to finish reading one, and a missed error message is gone for good — there is no notification history to go back to. Errors and warnings now hold for six seconds. Success and informational toasts keep the three-second default, so the common case of clicking something and seeing it confirmed is unchanged, and the close button and the manual dismiss work exactly as before on all of them. The background print-dispatch toast is unaffected: it stays up while it has work in progress and clears itself shortly after the last job settles. Covered by frontend tests. - **The bug-report button no longer covers the controls in the bottom-right corner (#2750, reporter @goodjaltman)** — On a phone the floating red button sits on top of whatever else is in that corner, which turns out to be most things: the scroll-to-top button on Profiles was ~83% underneath it and, since both sit at the same stacking level, which one you could actually tap came down to the order they happened to render in. The floating camera window parks there, as do the Group Edit save bar, the bulk-selection toolbars, and — because the button is pinned to the viewport rather than the page — the per-card action buttons on File Manager and Archives simply scroll underneath it. The reporter asked for a switch to hide the button, but it is the only way into the report form, and that form is not just a text box: it runs the printer connection diagnostic, scans your logs against the known-issue catalog, optionally captures five minutes of debug logging and attaches a support bundle. Hiding it doesn't produce smaller reports, it produces reports with nothing attached. So the button moves instead of disappearing. Once the window is narrow enough that the sidebar collapses into a menu button, the bug icon moves into that top bar and the corner is left alone; above that width nothing changes. That threshold is the one the layout already switches on, so there is no new breakpoint and no third state to reason about, and it covers tablets and half-width desktop windows rather than only phones. The report form itself is now a proper bottom sheet on phones, which also fixes it hanging 16 pixels off the left edge of the screen — it was sized to the full viewport width and then inset from the right, so a strip of the form was simply unreachable on anything under about 460 pixels wide. The scroll-to-top button on Profiles has been nudged clear of the corner as well, for the wide layouts where the floating button stays. Wiki updated. Covered by frontend tests. - **The Docker update command is copyable, and knows where your compose file lives (#2664, reporter @pchulpjoost)** — Settings → Updates told Docker users to run `docker compose pull && docker compose up -d`, a command that does nothing unless you are already standing in the directory holding your compose file — which is exactly what you came to the page not knowing. There is now a copy button beside it, and the command includes the `cd` when Bambuddy knows where to send you. It can work that out on its own only when you bind-mount a subdirectory of that folder (`./data:/app/data` gives it away); the shipped compose file uses named volumes, which resolve to a path under Docker's own storage and say nothing about where your file is. Compose does record the answer on every container it creates, but reading it back needs the Docker socket mounted into Bambuddy — root-equivalent access to your host in exchange for a convenience string, which is not a trade worth offering. So there is a **Compose directory** field in the same panel, saved with the rest of your settings, and a `BAMBUDDY_COMPOSE_DIR` variable in the compose file for anyone who would rather keep it there. Leave it empty and the command is printed without the `cd`, rather than with a guessed path that fails on paste. The field accepts path characters only: it is the one setting whose whole purpose is to be pasted into a root-capable terminal, so a value like `/opt/bambuddy; rm -rf /` would otherwise render as a perfectly plausible update command. Translated in all locales; wiki updated. Covered by backend and frontend tests. - **Server-side slicing is no longer offered for STEP files** — The Slice action appeared on `.step` and `.stp` and the endpoint accepted the job, but neither slicer can load one from its command line. So the file was read, converted and uploaded before failing as "the input model file to the slicer can not be parsed", which reads as a corrupt model rather than an unsupported format. The endpoint now refuses a STEP up front with a message saying to export it as STL or 3MF, and the Slice and pipeline buttons no longer appear on one. **Open in Slicer** is unchanged and still hands a STEP to the desktop application, which opens it fine — that was always the working path. The two predicates are now separate so they cannot drift back together. - **Form controls follow the page's colour scheme** — The parts of a form control the browser draws itself — a number input's stepper, a date field's calendar button and popup, a select's dropdown, scrollbars, the autofill tint — were painted in the light appearance on every theme. Bambuddy switches theme by swapping CSS variables under a class, which the browser cannot see, so it assumed the page was light and matched the steppers to a white background that was not there. Declaring the colour scheme alongside the variables fixes all of them at once, in both directions. Three date and time fields that had been pinned to dark by hand to work around this no longer need to be — being pinned, they were wrong on the light themes. - **A print stage Bambuddy cannot name is now logged at INFO** — The stage table is hand-maintained and every new model adds to it, so a printer occasionally reports a number that is not in it and the card reads "Unknown stage (72)". An H2C did exactly that, where the table runs to 66 and then jumps to 74. Stage transitions were logged only at DEBUG, which is off in normal running, so the only record that it had happened was the card itself — and by the time anyone looked, the printer had moved on. The asymmetry is the point: a stage we can name is worth DEBUG, and the one we cannot is the interesting one. An unnamed stage is now logged once per stage number per session at INFO, with the model, the stage it came from and the print state at the time, which is what naming it afterwards needs. Named stages are unchanged, so a normal print logs nothing new. - **The Slicer Bundles notice is gone from Settings** — Bundle import was withdrawn in 0.2.5 and the panel was kept behind as a static notice pointing at the alternatives. It has been on screen for several releases, it was shown to everyone running the slicer sidecar whether or not they had ever imported a bundle, and it was a card in **Settings -> Queue & Dispatch** that could not be acted on. The component, its render site and all thirteen locales' strings are gone, and the remaining columns rebalance to fill the space. No slicing behaviour is touched. Four docstrings that still described the removed feature as a live fallback were corrected at the same time. ### Fixed - **An H2C levelled on one hotend and printed with another (#2800)** — An H2C ran its startup clean and bed levelling on one hotend, switched, and then printed several millimetres above the plate. The same job from Bambu Studio was fine. The H2C is the only model that mounts its nozzle from a rack of six, and a print command names that nozzle by physical rack position — the firmware reports those as 16 to 21 — not by the extruder index every other dual-nozzle printer uses. Bambuddy only ever had a rack position when a job arrived through the Virtual Printer, which captures Bambu Studio's pick and replays it (#1780); anything queued from the library, an archive, the webhook or a slicer pipeline carried none, so the field was omitted and the firmware chose for itself — and its choice need not match what the file was sliced for. The scheduler now derives the per-slot extruder assignment from the file it is about to send, and the MQTT layer resolves it against the rack position the printer reports live. Both halves are needed: the file knows which side a slot prints from, only the printer knows which hotend is in the carriage, and it can be swapped from the touchscreen between queueing a job and printing it. Two hardware-derived values were then corrected by the reporter's own A/B on real hardware. The rack is not on extruder 0 — a mixed-nozzle plate dispatched with the rack position on the wrong side printed several millimetres above the bed. And correcting only that is not enough, because the fixed hotend answers to physical id 1 rather than to its extruder index of 0, and forwarding the index produced a command the printer rejected outright. Translating both carriages gives the mapping the printer accepts, and the same sliced file then cleaned, levelled and printed on the correct nozzle at the correct Z through to completion. Both values agree with three native Bambu Studio captures from the same machine. The carriage assignment itself was inverted alongside: extruder index 0 was treated as the fixed hotend and index 1 as the rack, and it is the other way about. The printer's own telemetry, a Bambu Studio dispatch that completed, and the two constants failing to agree with each other all say so; the reasoning is now recorded at the constants so a future regression report is not re-litigated from scratch. - **An H2C refused a multi-colour print as a hotend mismatch** — The print uploaded, the printer took the command, and stopped at once with HMS 0500-4047 — "the available hotend quantity or model does not match the sliced file". The nozzle mapping told the printer one of the plate's filaments went to no hotend while the AMS mapping named the tray it comes from, and the firmware will not start a job on that contradiction. Each filament in a 3MF names the group it belongs to, and on every other dual-nozzle Bambu the group number is also the extruder index, so it was read as one. On a rack machine it is not: the rack carriage holds six hotends to the fixed carriage's one, so the slicer writes a group per nozzle rather than per carriage. The failing plate carried groups 0, 1 and 2 against a two-entry map, and the filament in group 2 was dropped — indistinguishable downstream from a slot the plate does not print, which is what reached the wire. The group is now resolved through the table the file states for itself in its slice info. Files carrying no such table keep the direct index, so H2D slices are unaffected, and a filament that still cannot be placed drops the whole mapping with a logged reason rather than sending half an answer. - **The nearest filament colour is picked, not the first one in tray order (#2804, #2823, contributor @grolmus)** — When the exact colour a plate asks for is not loaded, Bambuddy substitutes an eligible spool. It took the first eligible one in tray order, which is an arbitrary answer to a question that has a right one. It now ranks them, and the ranking is perceptual. The first pass measured RGB distance, which rates a colour by how far apart the numbers are rather than how far apart they look, and it overweights blue badly enough to invert the answer: against a required dark green, a purple was the nearer of two eligible spools by RGB and four times the further once measured properly. Both sides now use CIEDE2000, with the backend and frontend implementations kept structurally identical so they can be read side by side — verified against the published reference set to 1e-4 on all 31 pairs, and the two implementations agree to within 1e-9 across 800 sampled pairs. Eligibility is untouched, still the per-channel box, so this only reorders spools that already qualified. Filament type matching now agrees between the interface and the scheduler as well: Bambu firmware treats PA-CF, PA12-CF and PAHT-CF as one material and the scheduler has always matched them accordingly, but the interface compared raw type strings and called that same pairing a mismatch — so the badge contradicted what the printer was about to do. - **Archives that arrived empty, from printers whose file service could not answer (#2780)** — Two faults behind the same symptom. Bambuddy reads a print's 3MF, cover and timelapse over FTPS on port 990, which on every Bambu model serves external storage only. Under some configurations H2-series and P2S firmware keeps the sliced file on internal storage, where Bambu Studio put it over a different service, and then no path on 990 can find it. The print command has always said which of the two it used, and we discarded the field and swept anyway: around 110 connections per print, every one certain to fail, ending in an archive card with nothing on it and no stated reason. In the reporter's bundle all 35 dispatches to their H2C and P2S said internal storage, all 25 to their X1C said external, and all 44 empty cards belonged to the first two. The field is now read, the sweep is skipped when it cannot succeed, and the reason is recorded. A printer that uses the card is unaffected, and so is one we have no answer for — silence is not evidence, and reading it as bad news would break archives that work today. The answer is held per print and dropped when that print ends rather than kept as a standing fact about the printer, because plenty of prints never announce themselves: 14 of the 79 print starts in that bundle arrived with nothing on the request topic, started from the printer's own screen or picked up after a restart, and one slicer print to internal storage left standing would suppress the lookup for every screen-started print after it. Separately, two printers went on printing while every archive they produced held nothing but a filename: Bambuddy opened port 990, the printer accepted the connection and answered with something that was not TLS, and the connection failure was indistinguishable to every caller from "the file is not at this path". So the 3MF lookup walked all six filename variants across five directories with four retries each, the cover endpoint ran its own sixteen-path sweep, and the timelapse scan added four more, all against a printer that could not have answered any of them — one reporter's log carried 1813 identical handshake failures, another's 3511. The evidence says this is the printer's own file service getting stuck rather than a model, firmware or TLS-configuration problem: in one bundle the same two printers ran clean for two weeks and failed from a fixed date, in another an X2D served files for five days, flipped, then failed every connection for eight days with no successes, while the same models and firmware appear in roughly twenty other bundles with no occurrences at all. A TLS error on connect now opens a five-minute cool-off for that printer, so a wedged printer is contacted twice an hour instead of hundreds of times a minute, and the single warning that is logged names the remedy. The sweeps stop early while it holds, and the cover endpoint returns a status naming the file service instead of a 404 that reads as a missing file. - **A completion for one print closed another print's queue item (#2829)** — Bambuddy has no run identifier tying a completion to a queue row, so it finds the row by printer and printing status alone. Any completion delivered for a printer therefore closed whichever job was printing on it: the job was marked completed while the printer was still working, left the queue for history, and stranded the rest of its batch, because the queue correctly refuses to dispatch onto a busy printer. The handler now checks the completion against the file the row was dispatched with, recovered from its archive, and leaves the row alone when they disagree. Only a positive disagreement refuses — no archive, no file name or no subtask name is unverifiable rather than wrong, and refusing those would strand the item and wedge the queue, which is the failure the loose lookup was avoiding in the first place. That check then had to learn how the printer writes the name. It does not echo it verbatim: it substitutes underscores for spaces, so a title with spaces came back with underscores, the check refused it, and the row stayed printing — and since every printing row counts as a busy printer and nothing else ever closes one, that printer's queue stopped until someone cancelled by hand. It also truncates long names and marks the cut, which would have done the same to any long title. The comparison is now on the canonical form — case, spaces and underscores — which is the rule the 3MF lookup in the same module has always used for the same names, and a truncation marker is treated as a prefix match. The check keeps its purpose: the same printer the same day still correctly refused a completion for its own calibration run. One comparison being stricter than reality should not be able to stop a queue indefinitely either, so the scheduler now closes a row itself when it has been printing for five minutes after its printer went terminal, with the status that state implies. A real completion arrives within seconds, so this only ever sees rows that were already stranded, and a disconnected printer never qualifies. It restores the queue only — notifications, billing and auto-off are not replayed minutes late. - **Deleting a library file destroyed the jobs queued against it (#2819)** — Nothing tied a library file to the queue rows pointing at it, and the foreign key that describes the relationship cascades on delete — which SQLite does not enforce and PostgreSQL does. So the same fault had two faces: rows left pointing at a file that no longer existed, failing at the printer with "library file not found" days later, or rows deleted outright with no error and no history. Two routes led into it, and both are fixed by taking the queue off the file before the row goes. In the reported case, a quantity above one on the printer card's upload-and-print flow puts a cleanup-after-dispatch flag on every copy and the clone carried the library file id onto each one, so the first dispatch consumed the file the rest were waiting on; the copies are now pointed at the archive that dispatch just created, which holds its own copy of the file. - **A library-backed job was dispatched onto a spool that could not finish it (#2779)** — A job needing 20.5 g was dispatched onto a spool holding 9 g and the printer started. The source resolver returned the library file's stored path verbatim, but that column holds a path relative to the data directory — so it resolved against the process working directory, found nothing, and the deficit check treated a missing source as "nothing to verify" and reported no deficit. Every library-backed queue item was affected: slicer pipeline jobs, which are always library-backed, and everything added through the Library's bulk **Add to queue**. Both callers share the resolver, so the Play button on the queue was as blind as the auto-dispatcher. Archive-backed items were never affected, and neither was the print dialog, which resolves the file on its own path. The library branch now uses the same idiom as the eleven other readers of that column: absolute stays, relative joins the data directory. - **A job queued to a printer class never powered a printer on (#2786)** — Queue a print against a printer class — "Any X1C", or a slicer pipeline whose target is a printer class — with every printer of that class switched off, and nothing happened. The job sat pending and no smart plug was touched, while the same file pinned to a specific printer powered that printer on within one queue check. The reporter's log holds both halves: thirteen minutes of the item being polled and passed over, then a change to a specific printer, then "attempting to power on via smart plug" on the very next tick. Same item, same plug, same auto-on setting. Powering a printer on had only ever been written inside the branch that handles a pinned printer. The model-based branch below it walks the same queue, but its matcher classes an offline printer as a reason to keep waiting and nothing on that path ever looked at plugs. It now wakes a printer of the class the same way the pinned path does. - **Spoolman no longer charges a Bambu Studio print to the wrong spool (#2768)** — A sliced file numbers its filaments 1, 2, 3, 4, and which AMS tray each of those came from is a separate decision made when the job is sent. Bambuddy learns that decision one of two ways: it made the choice itself, for a print started from Bambuddy, or it read the print command as it crossed the local network, for a print sent from a slicer. A job dispatched from Bambu Studio while the printer is signed in to Bambu's cloud satisfies neither — the command travels through Bambu's own broker and never appears on the network Bambuddy is listening to. With nothing recorded, the Spoolman writer fell back to assuming the AMS was loaded in slicer order: filament 1 from the first loaded tray, filament 2 from the second. The reporter's X1C was loaded in the order 2, 4, 1, AMS-HT, so every one of the four was deducted from the wrong spool. It also changed what the print looked like afterwards: on completion Bambuddy stamps the archive with the material and colour of the spools it charged, so the print showed the right filament while it ran and switched to a different one the moment it finished — which is how the reporter noticed. The printer knew the answer all along. It publishes the running job's slot-to-tray assignment in its own status, and Bambuddy's built-in filament inventory has read that field for as long as it has resolved mappings at completion; only the Spoolman writer, which resolves at print start instead, never learned to. It now consults the same two fallbacks at the same moment: the printer's report first, and failing that a colour match of the sliced filaments against the loaded trays, which covers the A1, A1 Mini, P1S and P2S — those models publish no such field, so their owners were on the positional guess no matter how the print was sent. Reading the field at completion rather than at print start is deliberate: a printer keeps publishing the last job's mapping while it sits idle, so consulting it early risks stamping the previous print's mapping onto this one. A mapping Bambuddy or the slicer actually recorded is never second-guessed, so nothing changes for prints started from Bambuddy, from the queue, or over LAN. Cancelled and failed prints take the same correction, since partial usage is charged through the same mapping. The resolved mapping and where it came from are now logged at both print start and completion, so the next report of a wrong deduction can be read straight out of a support bundle. Wiki updated. Covered by backend tests. - **"Any X2D" works on a printer that feeds from external spools instead of an AMS (#2771, reporter @Nick-C130)** — A fleet of five X2Ds with no AMS units, each printing PETG from its external spool holder, accepted a job sent to a named printer and refused the same job sent to **Any X2D**: the file uploaded, the printer answered "Failed to get AMS mapping table", and after three attempts the queue item failed. The two paths differ in one thing. A job queued for a named printer carries a filament mapping the browser worked out at the time you queued it; a job queued for a model has no printer yet, so the scheduler has to work the mapping out at dispatch — and its copy of that logic could not see an external spool on a dual-nozzle printer. On an X2D or H2D each filament in the sliced file names the nozzle it feeds, and Bambuddy will only offer a spool to the nozzle it is physically plumbed to. Which nozzle an external spool feeds was being read off a table the printer builds from its AMS units, so a printer with no AMS published an empty table, every external spool came back belonging to no nozzle at all, and the per-nozzle check discarded the only filament on the machine. Nothing matched, and the print went out claiming to use an AMS while carrying no mapping — which is the message the firmware was objecting to. The left and right external feeds identify themselves well enough to be routed without that table, and the printer reports its two nozzles directly, so both are now used. This is the same fault that was corrected in the browser last May for exactly this hardware; the scheduler kept the old logic, which is why the browser-resolved mapping worked and the scheduler-resolved one did not. Single-nozzle printers are untouched — they have no nozzle to route to and never took this branch. Separately, a job whose filament genuinely cannot be matched on a printer with no AMS now fails immediately and says which filament is missing and which nozzle wants it, instead of uploading several megabytes, collecting the firmware's error and failing anyway two retries later; where there *is* an AMS the firmware error still stands, because there the job can be recovered by loading a spool and pressing **Resume**. Covered by backend tests. - **Live updates stopped arriving while the Bambuddy tab was in the background (#2754, reporter @mic4rd)** — The progress percentage in the tab title froze whenever you switched away and jumped straight to the current value the moment you came back, which defeats the point of putting it in the title. There were two causes, and the first fix only got one of them. Every printer status arriving over the WebSocket was written into the browser's cache from inside an animation-frame callback, and a browser gives a hidden tab no frames at all — those callbacks are not slowed down, they are held, so the connection stayed up, the messages kept arriving, and every one of them parked in a queue that only ran when the tab was shown again. The same applied to the archive, inventory and spool refreshes, and to the queue carrying every non-status message, which stalled completely. Removing the frames fixed that stall but not the report, because the write still went through a 100 ms timer that batches rapid updates — and a timer is exactly what a browser throttles in a tab you are not looking at, to roughly once a second, and to about once a minute once the tab has been hidden for five minutes. The reporter's screenshot showed a tab title reading 2% beside a page at 40%. That batching exists to stop a burst of messages causing a rendering pile-up, and a hidden tab is not rendering, so there is nothing to protect there: while the tab is hidden the value is now written straight through, and the batching still applies while you are looking at it. Worth knowing if you use Windows: a browser window completely covered by another window counts as hidden, not merely unfocused, which is why this could bite without ever switching tabs. Covered by frontend tests that reproduce a hidden tab, including one that never advances the clock — the earlier tests passed by simulating the very timer the browser was throttling. - **LDAP login works again on directories that define no POSIX group class (#2769, reporter @peterskotte)** — Every LDAP user on an lldap directory was rejected with "Incorrect username or password", including users whose credentials, search filter and group membership all checked out when tested by hand with `ldapsearch`, and on an install where **Test Connection** reported success. The password was never the problem and the directory never saw the request. When resolving a user's groups Bambuddy looks for POSIX groups alongside the usual `memberOf` ones, and both of those searches name the `posixGroup` object class. The LDAP client validates class names in a filter against the schema the server publishes, and rejects an unknown one while building the request, before anything is sent. lldap marks every account it creates as `posixAccount`, which is what makes Bambuddy look for POSIX groups in the first place, but defines no group class beyond `groupOfNames` — so the search was refused, the refusal travelled all the way out of the login routine, and the login route reports any LDAP failure as bad credentials. A directory with no `posixGroup` class has no `posixGroup` entries, which is precisely the answer those searches would have returned, so Bambuddy now treats the refusal as the empty result it stands for, notes it once in the log and carries on with the `memberOf` groups. The reporter's mapped group is one of those, so it resolves as configured. This is not a regression from the recent primary-group work, though that is the natural suspect: the `memberUid` search has named the same class since LDAP support first shipped, and it runs for every user whether or not they have a `gidNumber`, so login has never worked against a directory of this shape. **Test Connection** passed throughout because it asks only whether any entry exists, a form of filter that carries no class name to validate. Nothing changes for Active Directory or for an OpenLDAP that loads the standard NIS schema — both define the class, and their POSIX groups are still read. Wiki updated. Covered by backend tests. - **A hand-written systemd service left the Virtual Printer unable to start, with nothing obvious to blame (#2549, reporter @Ru3ck3)** — The Virtual Printer binds ports 990 and 322, both below 1024, which a service running as a normal user may not do without the `CAP_NET_BIND_SERVICE` capability. Without it the rest of Bambuddy works perfectly and only the Virtual Printer is dead: its sockets never open, the slicer never finds the printer, and the sole trace is one line in the journal. The reporter lost days to this before someone on Discord spotted the missing line. The install script has carried it since March, but the three other places that define the same service did not — the manual-install template, the combined Bambuddy plus SpoolBuddy installer, and the unit the wiki tells you to paste. All three have it now, and the wiki no longer claims the capability is always included when its own instructions omitted it. Bambuddy also diagnoses this itself: **Diagnose** on the virtual printer card previously reported only that nothing was listening on port 990, which reads identically to an ordinary port conflict. It now checks whether the process actually holds the capability and, when that is what is wrong, says so and gives the line to add. The check stays quiet when the port is answering, since fronting it another way (an iptables redirect is the documented alternative) is a legitimate setup, and it stays quiet when the capability is held, so a port that failed for some other reason is not misattributed. Existing installs are unaffected until reinstalled; the diagnostic tells you whether yours needs the line. Translated in all locales; wiki updated. Covered by backend tests. - **Bambu Cloud's anti-robot challenge is explained instead of repeated (#2790)** — A reporter tried to connect to Bambu Cloud and got "we need you to confirm you are not a robot" as an error toast, with no challenge anywhere to answer and nothing to click. That sentence is Bambu's, not ours: their anti-abuse layer had flagged the network and was answering the sign-in with a challenge body. Bambuddy had no idea what that was. The reply is well-formed JSON, so the Cloudflare detection — which triggers on an unparseable body, on Cloudflare markers, or on particular statuses — never fired on it, and the login fell through to the generic error path, which lifts the message out of the body and hands it to the interface verbatim. The user was left to conclude their password was wrong or that Bambuddy was broken; four sign-in attempts inside eighteen seconds appear in their log, each one more evidence for the thing that had flagged them. The challenge is now recognised for what it is and explained, with what to do about it. - **Interactive controls show a pointer cursor again (#2791)** — Hovering most of Bambuddy gave an arrow rather than a hand. Not everywhere, which is what made it read as sloppiness rather than as a bug: the update pill was inert while the buttons beside it were fine, a bed or nozzle tile responded but the history-graph button in its corner did not, and dropdowns went either way with no pattern behind it. The pattern was there. Tailwind v3's base styles set a pointer cursor on buttons; v4 dropped it to match the browser default, which for a button is an arrow. Bambuddy has been on v4 since the frontend was built and never had a base rule restoring it, so a button only looked clickable where someone had written the class by hand — 15 of 934 buttons had, 0 of 149 selects, and 19 of 130 checkbox and radio inputs. The 233 ad-hoc usages are why it looked arbitrary instead of uniformly broken. One base rule now covers buttons, selects, checkboxes, radios and summary elements. - **A refused frame no longer leaves the browser's own error page inside Bambuddy (#2787)** — A reporter uploaded an STL, sliced it, and got a frowny icon and "refused to connect" when previewing the sliced file, while the STL's own preview worked. That is the browser's blocked-by-response page drawn inside our layout shell, and the split between the two previews is where the cause is: an STL or source 3MF renders in the page, a sliced file opens the G-code viewer, which is the only thing in Bambuddy that frames a Bambuddy page. Our own headers permit that frame and the frame is same-origin, so a refusal means a stricter header was added after we replied — a reverse proxy, a security add-on, an auth gateway — none of which the user could see. Bambuddy now says which header blocked it and points out that the viewer opens perfectly well in its own tab. - **The Spool Inventory header no longer scrolls the whole page sideways (#2813)** — Five header buttons in a row that could neither wrap nor shrink came to roughly 600 pixels, so on a 390-pixel screen the header ran past the viewport and took the whole page with it, since the main element is the scroll container. They now stack on small screens and wrap, matching the pattern the Statistics, Settings and Archives headers and this page's own filter bar already use. Identical at 640 pixels and above. The System Information header had the same construction and gets the same treatment. - **The Virtual Printer card header wraps instead of painting outside its border (#2808)** — Every item in the collapsed header refused to shrink, so the row was as wide as its contents and the card does not clip — the remote-interface address and the enable toggle painted outside the card border. The name's truncation could not save it, because a flex item does not shrink below its text by default. The metadata now wraps to a second line while the chevron, status dot and toggle stay outside that group. Wrapping rather than truncating, because the bind and remote-interface addresses are what the page exists to show. It needs three things at once to appear, which is why it took a report: both addresses set, a target named from discovery, and the three-column card grid. - **The printer card thumbnail is back after navigating away and returning (#2826)** — The cover URL is cache-busted on the print name, which does not change while a print runs, so leaving the printers page and returning re-mounts with a byte-identical source — which the browser serves from its in-memory cache with no network request. That is why the reporter's network panel was empty while the placeholder sat there. The loaded flag could only ever be set by the load event, and a mount effect reset it unconditionally; for a cache hit those are two racing tasks with no ordering between them, and when the load event won, the effect undid it, with nothing to put it right afterwards because the URL does not change again for the rest of the print. The effect now reads the element's own state instead of assuming nothing has loaded, which settles it whichever task wins. Being a race is why it reproduced every time for the reporter and not at all here. Only the printer card was affected — archive thumbnails build a fresh URL every mount and always hit the network. - **A slice failed on a model whose name contains a slash (#2832)** — A print's display name comes from inside the 3MF rather than from the filename, so a MakerWorld title arrives with its punctuation. The slice-to-archive sink used it verbatim for the output folder and the output file, and a slash in a folder name is not a character — it is another folder. Creating parents made the level it implied and the file's own join added a third that nobody had made, so the slice failed with a missing-path error on a path that half existed, and renaming the print first was the only way through. A display name is now reduced to a single path component before it becomes one. Characters a name cannot hold are replaced rather than dropped, so the folder still reads like the model's title, and the set is the one the SD card already rejects — which covers a Windows install too, where a colon fails the same way. The name shown in Bambuddy is untouched: a title is allowed its punctuation, and refusing the slash would reject the name this was reported about. The joins are now genuinely asserted to stay under the archive directory, which a stale marker had been claiming without it being true. The library sink takes the same embedded name and gets the same reduction — managed storage names the file after a UUID and never saw this, but an external folder writes the name as given. - **A slice of a file on a network share was written to managed storage instead (#2810)** — Slicing always wrote to the managed library directory while giving the new row the source folder's id, so slicing a file on a NAS mount produced an output that showed up in the right folder in the interface and never reached the share — invisible from the web interface, which is why it did not reproduce. The destination is now resolved from the target folder the way uploads and moves already do, marked external, and stored with its absolute path. Collisions uniquify rather than failing, because a conflict would throw away minutes of CPU on a routine re-slice and overwriting a file on someone's NAS is worse. An external folder that cannot take the file falls back to managed storage rather than discarding the slice, and says why in a warning — silent fallback is what made this invisible. - **A 3MF no longer switches off supports its process preset turned on (#2820)** — Since #1881 four support fields travel from the source file over the picked process preset, because the loaded settings are authoritative and Bambu's shipped presets all disable supports — so without the carry, a project exported with PVA in the interface slot sliced single-material. But the carry ran in both directions, and the off direction is the one nobody asked for: nearly every published model ships with supports off, so slicing one against a custom preset that deliberately enabled them stripped them back out. The reporter's preset enabled supports with a normal auto type; the slice came back disabled and tree auto, and only the style survived because it is not one of the four carried. The source can now switch supports on and never off. Nothing is lost, because every shipped preset has them off, so a preset that has them on is a deliberate choice. - **An oversized model reads as an oversized model, not a slicer crash (#2802)** — The sidecar caps model uploads and reported a rejection as a bare 500 "file too large", because that error is not the sidecar's own error type and falls through to the default status. A 500 reads as a crash inside the slicer, and the one message Bambuddy had about request size was written for the status a reverse proxy sends, so it never appeared. The reporter tried three unrelated environment variables, stopped nginx, and moved from Windows to Docker, none of which the sidecar reads. The rejection is now matched by what it says rather than by its status, so an installation still on an older sidecar image gets the same explanation. The match is strict — the body must be only that message — because a genuine slicer failure is also a 500 and has to keep reaching the embedded-settings fallback. Old images are told to update, since they have no setting to change; current ones are told which setting to change. The update advice was wrong too: it told users to run a bare `docker compose pull`, and the sidecar is declared behind a compose profile, which compose skips silently — so the pull was a no-op for exactly the users the message was written for, and the restart policy kept the old container serving. The reporter pulled, restarted, raised the limit and got the same rejection, because the image never changed. Both commands now name the service, which enables the profile implicitly without pulling a second 220 MB image onto a host that never asked for it. - **A preview slice no longer gives up on custom G-code the sidecar cannot parse** — Opening the slice dialog on an unsliced project runs a preview slice purely to ask the slicer which AMS slots the chosen plate consumes. Bambu Studio 2.8 writes a conditional into the machine's timelapse G-code but exports no definition for the variable it tests, so the template is unresolvable the moment it leaves Studio and an older sidecar stops with a placeholder parse error before producing any slice information. The preview returned nothing and the caller silently fell back to guessing from painted faces — on the H2D project this was found with, the guess dropped the support material, a whole slot off a four-filament plate. The preview is now retried once with just the named template emptied, still on the file's own settings. Keeping the embedded settings is what keeps the answer honest: overriding the process preset instead discards the project's support configuration, which loses that slot and moves the filament estimate by up to a factor of two. Measured against the same file, the retry reproduces all four slots gram for gram while a printer-and-process override returns three. - **Bundled presets resolved to a generic start G-code block** — A bundled preset can keep a setting in a companion file the preset itself does not reference, which the desktop slicer finds by name. Walking only the inheritance chain never reached it, so every one of the 56 instantiable BBL machine presets resolved its start G-code to the 577-character generic block instead of its own 6.5 to 21 KB one. That block holds the AMS load and the claim-action calls, so a print sliced from it heats the bed, moves the toolhead and extrudes nothing. Companions are now folded into each ancestor as the chain is walked, at that ancestor's precedence, so a caller's own value still wins and the 0.2, 0.6 and 0.8 variants reach their 0.4 sibling's companion; they are found by listing rather than by a fixed set of keys. Covered against the shipped bundle rather than fixtures — a new end-to-end spec resolves all 56 presets inside the image and fails on any that still lands on the generic block. - **AMS drying was torn down and restarted once per scheduler tick (#2801)** — A printer in FINISH with an unacknowledged plate and something pending in its queue stopped and restarted drying on every scheduler tick, for as long as the plate stayed unacknowledged; the reporter's Home Assistant history recorded about 2000 state changes over ten days. No cycle ever ran long enough to remove moisture, and cycles the user had started by hand on other AMS units of the same printer were torn down with them. Two concerns had become tangled. Plate-clear answers whether the bed is ready for the next job and says nothing about whether the AMS may heat, and the gap between a finished print and the acknowledgment is when drying is most useful — the printer is free and nobody is waiting on it. Leaving a plate unacknowledged is also how people hold the queue by hand, so the hold was costing them the drying it should have enabled. - **Auto-drying re-armed into a threshold it could never reach (#2770)** — An H2D armed five twelve-hour drying cycles inside four hours, one of them six seconds after the previous one ended, and none ran more than a couple of hours. Two things combine. The firmware ends a cycle when it decides the filament is dry rather than when the clock runs out, and reports no fault doing it — across this printer's history the run length tracks how wet the spools were, from nearly the full twelve hours starting at 32% down to minutes once the unit sat at 10-13%. That part is the AMS doing its job. The loop is ours: an AMS reports a higher relative humidity while it is warm than once it has cooled, and the same unit read 10-13% cold against 15-20% through every cycle, so with the threshold set between the two the reading at the moment a cycle ended always re-armed the next one. - **A drying cycle the printer abandons now says so, and says what the printer reported (#2770, reporter @tchavei)** — An H2D started a twelve-hour PETG dry at 65°C and the AMS gave up on it twenty minutes in, with 700 of the 720 minutes still on the clock. It then cooled off, humidity climbed back over the threshold, auto-drying started another twelve-hour cycle, and that one was abandoned the same way — a loop the reporter's AMS temperature history shows running all morning. The log had one line to say for it: `AMS 0 drying complete`, which is exactly what it says for a dry that ran its full twelve hours. Nothing in a support bundle told the two apart, and the remaining time — the one number that does — was written into the line as the *previous* value, where it reads like a duration rather than a shortfall. Bambuddy did not stop that cycle. Every stop it sends is logged with the full command as it goes out, and there was none, so ending it was the printer's decision — and the only account of why lives in three things Bambuddy already receives and parses but has never written down: the drying phase and sub-phase the AMS reports in its status word, the firmware's own cannot-dry reason codes (which distinguish an overheating unit from one being starved of power by a missing external supply), and whatever HMS errors are live at that moment. A cycle that ends with most of its countdown left now logs all three, alongside how much of the requested duration actually ran and how much was asked for. A cycle that reaches its configured duration keeps the single line it has always had, so a normal dry does not start reporting diagnostics nobody needs, and a cycle Bambuddy itself ends — the print-takes-priority stop, or the **Stop** button — now says so by name rather than being reported as an unexplained early end, since a stop is short of its duration too and looks identical in the telemetry. This is diagnostics only: nothing about when drying starts or stops has changed, and the repeated restart itself is not addressed here — what the firmware objects to has to be established before Bambuddy can sensibly decide how long to wait before trying again. Covered by backend tests. - **A drying cycle no longer reports itself finished a minute after it starts (#2759)** — Starting the dryer on an AMS 2 Pro holding two PETG and two PLA spools and picking PLA showed "PLA @ 45°C" for about a minute, then switched to "PETG @ 65°C" for the remaining twelve hours. Bambu never echoes back which filament or temperature a cycle is running, so the badge reads the target Bambuddy cached when it sent the command — and that cache had been thrown away. Between accepting the command and settling its countdown the firmware publishes one update with the remaining time at zero while the unit is still in its Checking phase; the reporter's log caught 720 minutes, then 0, then 719. Bambuddy read the zero as the cycle ending. Losing the cached target left the badge to guess the filament from the first loaded slot, which happened to be PETG, and its RFID-recommended 65°C — a confident wrong answer for a cycle running PLA at 45. The same false ending also armed smart-plug auto-off-after-drying, so anyone with that switched on had power scheduled to cut one minute into a twelve-hour dry. A remaining time of zero is now only treated as the end of a cycle when the AMS also reports an idle phase, which the firmware already publishes alongside it; stopping a dry early still ends it immediately, and a unit that reports no phase at all still ends its cycles as before. The fallback guess has been tightened to match, in both directions. It names a filament only when every loaded spool agrees on one — on a mixed unit the badge shows the countdown alone rather than naming a spool the cycle isn't drying — and it no longer guesses a temperature at all. A unit loaded entirely with PLA does tell you what is being dried, but not at what temperature: that is picked freely when the cycle is started, so the spools' RFID-recommended value is never evidence of it, and a second AMS loaded only with PLA and drying at 45°C still read "PLA @ 55°C" whenever the cached target went missing. The badge now names a temperature only when Bambuddy sent it, and shows the filament and countdown without one otherwise. Covered by backend and frontend tests. - **The drying badge invented a temperature on a uniformly loaded AMS (#2759)** — A second AMS 2 Pro with no auxiliary power, loaded entirely with PLA and drying at the 45 °C the reporter picked, showed 45 °C and then switched to 55 °C. Bambu never echoes back a cycle's filament or temperature, so both come from the target cached when the command went out, and the fallback for a missing cache reads the loaded trays. The first pass narrowed that fallback to units whose spools agree on a filament, which fixed the mixed-unit case in the original report but left the uniform case answering with the spools' RFID-recommended drying temperature. Agreement across slots is evidence of what is being dried, because the dryer heats all of them; it is no evidence of the temperature, which is picked freely in the popover. The badge now says nothing rather than guessing. - **The drying popover no longer starts a cycle under a material you did not pick (#2774)** — An AMS-HT loaded with Support for PLA/PETG offered PLA in the drying dialog's filament list, and the cycle that started was labelled Support for PLA/PETG on the printer's own screen. Opening the dialog prefills it from the spool that is loaded, and Bambu reports that spool's material as `PLA-S` — a name Bambuddy's table of drying temperatures does not carry. The temperature and duration fell back to PLA's 45°C for twelve hours, which is what the dialog showed and what was sent, but the material did not fall back with them: it stayed `PLA-S`, and the dropdown, handed a value that is not one of its options, displays its first option without saying so. So the list read PLA while `PLA-S` was what left for the printer. Anyone who opened the dialog and pressed **Start** without touching the material was affected; picking any entry from the list, even the same one it was already showing, made the two agree again. The same gap covered every composite — a spool of PETG-CF, PLA-CF, ABS-GF or PAHT-CF prefilled the dialog at PLA's 45°C, far short of what those materials want, and sent its full name as the material. Bambuddy now resolves a spool's material to an entry the list actually has before either value is set, so what the dialog shows and what the printer is told can no longer disagree. Support materials and composites resolve to the material they are built on, so PLA-S dries as PLA and PETG-CF as PETG at 65°C, and nylon is recognised under the several spellings Bambu gives it. Anything genuinely unrecognised still falls back to PLA, deliberately the coolest setting in the table — under-drying an exotic filament costs a cycle, where defaulting to the hottest would deform a PLA spool. This does not address the other half of that report: a printer that keeps showing the material set from its own screen even when Bambuddy names a different one, which needs a capture of what Bambu Studio sends before anything can sensibly be changed. Covered by frontend tests. - **A print that never starts now says AMS drying was running, instead of blaming the SD card (#2758)** — Sending a job to an X2D with two AMS units mid-drying failed silently: the file uploaded, the printer accepted it and then simply stayed idle. Bambuddy waited out the start watchdog, re-uploaded the whole 3MF, waited again, and after three attempts gave up with advice to check the printer's screen and the SD card — while Bambu Studio, asked directly, said it could not start the job because of the drying. Bambuddy now watches the AMS drying telemetry it already receives across the dispatch window and, when a job never starts while a unit was drying, names the units in the failure message and records the correlation in the log from the first attempt rather than only after the retries are spent. This is deliberately a diagnosis and not a rule: the printers concerned support drying *continuing* through a print, so drying and printing are not in conflict as such, and the report also involved one AMS drying without its external power supply — which would make the start-of-print calibration a power problem rather than a drying one. Stopping the cycle automatically would therefore be acting on a guess, and could tear down drying the hardware was happy to continue. Until it is known which of the two is the real obstacle, Bambuddy tells you what it saw and leaves the call to you. The message for a stalled dispatch with no drying involved is unchanged. Wiki updated. Covered by backend tests. - **A refused AMS filament setting now says so in the log (#2756, reporter @Jostxxl)** — Configuring a slot publishes an `ams_filament_setting` command, and the printer answers it with a verdict. That answer was received and then thrown away at debug level, so a printer that refused the write left no trace at the log level support bundles are collected at. The reporter hit exactly that: six manual **Configure Slot** attempts on one X1C, every one returning success, every one read back by the #2582 verification as still holding the previous profile, and nothing anywhere to say what the printer had made of the command. A refusal is now logged with the printer's own `result` and `reason` alongside the AMS and tray it concerned. Only refusals are promoted — unlike the K-profile and drying commands this one is not rare, since every spool assignment and every K-profile re-apply sends one, and logging each acknowledgement would bury the line worth reading. The developer-mode probe is excluded as well: it sends this same command to the external slot specifically to watch it be refused on P1 firmware, so its failure is a measurement rather than a fault. Diagnostics only — nothing about which commands are sent or how they are built has changed. Covered by backend tests. - **Configuring an AMS slot shows up on the printer card straight away, without a page reload** — Setting a slot to a different filament from the printer card left the card showing the old one. Nothing was lost: the command reached the printer, the printer applied it, and reloading the page or waiting out the thirty-second fallback poll showed the new filament. It simply never arrived on its own. Bambuddy compares each status push from a printer against the last one it broadcast and stays quiet when nothing has changed, which is what keeps a machine mid-print from flooding every open browser tab several times a second. The comparison looked at each AMS tray's slot number, material and load state — and **Configure Slot** writes none of those. It writes the filament id, the colour, the profile name and the calibration profile. So changing PLA to a different brand or colour of PLA produced a push that looked identical to its predecessor and was discarded, while changing PLA to PETG came through immediately because the material had moved. That is also why **Reset** always worked: it clears the material. The comparison now covers the filament identity as well, so every kind of slot change reaches the card. Those fields only move when someone configures a slot or swaps a spool, so this adds no traffic during a print — the amount of filament left, which does tick down continuously, is still deliberately excluded. Covered by backend tests. - **Photos and filament accounting on archives that arrive without a 3MF (#1820)** — Two faults behind the same kind of print: one that arrives without a retrievable 3MF, which on an H2S is any job started from the printer's own internal library. Such an archive has no file path, and the empty path's parent is the current directory, so every site that derived the archive's folder from it landed on the data directory itself. The finish-photo capture spotted that and wrote somewhere else; nothing else did. The photo was written in one place and looked for in another — reads failed, deletes dropped the name and left the file, and the notification attachment never found the image. Hand-uploaded photos worked only because upload and read agreed with each other rather than with the capture. The question now has one owner and all four sites ask it, with lookups still checking the old shared location so photos already uploaded there stay reachable. Separately, the remaining-percentage fallback that stands in for a missing 3MF can charge nothing for several reasons and did so without a word. The AMS reading is coarse and, on the reporter's printer, noisy: it rises mid-print, swings five points over a job, sits at 100% through a 36-minute print on a fresh spool, and goes negative on a nearly empty one — which the start-of-print gate rejects, dropping the only slot that was printing. Two of their prints went uncounted for two different reasons and both read as "no spools updated", which is also what a print with nothing to charge reports. Each case now names the slot and the two readings, on the Spoolman path and on the internal-inventory path alike. - **Home Assistant notifications carry nested data through unchanged (#1441)** — The pass-through flattened anything below the top level of a notification's data, so a payload with a nested structure — the shape most Home Assistant actions expect for anything richer than a title and a message — arrived at the other end reshaped. Nested structures are now carried through as given. - **AMS Filament Backup no longer charges a whole print to the substitute spool** — Everything the completion path needs in order to split a print's filament across the trays it fed from lived only in memory: the dispatched plate and its slot-to-tray mapping, the spool-assignment snapshot, and the tray-change log. A print that outlived a restart lost all of it and fell back to what the printer reports at completion, which with AMS Filament Backup on is the substitute tray — so the whole print was charged to the spool that only finished it, while the spool that ran dry was charged nothing. That context is now persisted, tray changes are appended as they happen, and both the session and the printer's tray-change log are restored at restart recovery, seeded from the current tray when there is nothing to restore. - **The Print Log is reachable again once you have no archives** — The Archives page decided it had nothing to show before it checked which view you were on, so with zero archives the "No archives yet" card replaced every view including the log. The Print Log is a separate table that deliberately outlives the archives it refers to — deleting an archive only clears the reference, and clearing the log is its own action — so purging archives hid a history that was still in the database, with no way back to it short of re-adding an archive. The log view now renders its own empty state instead of borrowing the archive one. Wiki updated. Covered by a frontend test. - **The Print Log's cost and energy figures were never sent to the browser** — Bambuddy has been recording what each run cost and how much power it drew, but the two Print Log endpoints built their responses field by field and never mentioned `cost`, `energy_kwh` or `energy_cost`. A field nobody names comes back as its default, so the values arrived as nulls — indistinguishable from a column that genuinely holds nothing, with no error and no log line to say otherwise. The same trap had already swallowed the failure-cause classification once before. Both endpoints now validate straight off the database row, which removes the opportunity to forget a field rather than fixing the three that happened to be missing. Existing rows need no migration: the data was always there. Covered by backend tests. ### Security - **Cleared every remaining `npm audit` and `pip-audit` finding** — A full dependency pass across both ecosystems. `react-router` and `react-router-dom` move to 7.18.2: the router advisory had been carried as a documented exception in the audit gate because its only fix was a major version, and upstream backported it, so the exemption lapsed on its own — an entry only holds while the fix is a major. The allowlist is now empty; the machinery stays for the next one. `dompurify` moves to 3.4.13, which ships in the application but on a path that is unreachable here — no hooks are registered and the in-place mode is never used. `js-yaml` and `nanoid` are overridden, both development-only and reached through eslint and postcss; the eslint path calls only the safe loader, on a legacy configuration file this repository does not use. Verified with eslint, the production build and the full frontend suite on the new versions. - **Patched two build-time frontend dependencies flagged by `npm audit` (GHSA-r28c-9q8g-f849, GHSA-mh99-v99m-4gvg, GHSA-rgw5-rvv9-x895)** — `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.9`) for a denial-of-service via unbounded expansion. The first `brace-expansion` advisory was answered in 5.0.8 by capping the length of the combined result, but that cap covered only the accumulator the results are merged into and not the two intermediate arrays that feed it — so a small brace pattern could still exhaust the heap, fatally and beyond the reach of a `try`/`catch`, or stall the event loop for minutes. 5.0.9 bounds both arrays as they are built. Both packages 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. ## [1.2.5.2] - 2026-08-02 ### Added - **The print queue now shows when each job would finish (#2736, contributor @mpl1337)** — A queue row carried a job's print duration but not the clock time that maps to, so "if I start this one now, when is it done?" meant doing the arithmetic yourself, once per row — while the card for the running print has shown an ETA all along. Pending and staged rows now show one too, beside the duration, in your configured 12/24-hour format and styled to match the live one. It is deliberately a per-job answer and not a forecast of the whole queue: "start this now and it finishes at 14:30", not a projection of everything ahead of it. That only means anything for a job that could actually start now, so the ETA appears on exactly those. A job sits behind whatever is printing on its printer, and behind anything the scheduler would dispatch ahead of it — resolved in the same order the scheduler itself uses, including **Shortest Job First** when that is on — and stays quiet until it is genuinely next up, so three jobs stacked on one printer no longer quote the same finishing time three times over. Staged jobs are the exception in both directions: the scheduler skips them without claiming the printer, so they neither hold up the job behind them nor wait for it, and they show an ETA whenever their printer is free — which is the honest answer to "what happens if I press Start". Jobs scheduled for later, jobs blocked with a stated reason, jobs conditional on an earlier print succeeding, and jobs with no duration in their metadata show nothing at all. The time also keeps up with the clock instead of freezing at the moment the page was opened, and every row on screen is measured from the same instant, so two rows are always comparable. Frontend-only. Translated in all locales; wiki updated. Covered by frontend tests. - **Keep the AMS slots the slicer picked (#2700, contributor @Striker72rus)** — Bambu Studio and OrcaSlicer resolve which physical AMS tray feeds each filament themselves, right before sending. Bambuddy threw that away: a queue-mode virtual printer worked the mapping out again at dispatch time, from the filament type and colour baked into the 3MF. That is usually the better answer — it is computed against the printer's live trays and it respects **Prefer lowest filament** and the AMS-backup gate that goes with it — but it has nothing to go on when the match isn't unique. Two spools of the same red PLA, and the slot you deliberately chose in the slicer is a coin toss. A new per-virtual-printer **Save AMS mapping** toggle keeps the slicer's pick instead: the print dispatches to exactly those trays, and the mapping is stored on the archive so a reprint can reuse the same physical spools — a **Mapping** button in the print modal selects every slot from it in one click, and the archive card and queue row say so. Because a tray number only means something on the AMS it was resolved against, the mapping records its printer and is only ever offered on that same printer; a model-based ("Any [model]") virtual printer has no fixed printer and is unaffected. Off by default, so nothing changes for existing virtual printers until you turn it on, and **Force color match** still wins for the print being dispatched when both are on. Translated in all locales; wiki updated. Covered by backend and frontend tests. - **P2S/X2D accessory fans: left auxiliary cooling and chamber exhaust (#2691, contributor @gzimbric, requested in #2660)** — The P2S and X2D have two fans Bambuddy could not show or drive. The **left auxiliary part cooling fan** had no tile and no control at all, because the printer only reports it inside its air-duct data and never in the ordinary fan fields Bambuddy was reading. The **chamber exhaust fan** had the opposite problem: its tile appeared on every P2S whether or not the fan was fitted, so owners of a base machine had a control that did nothing. Both are add-on kits on the P2S and fitted at the factory on the X2D. Both tiles now appear only when the printer itself reports the hardware, so a base P2S looks exactly as it does today and a kitted one gains the fans it actually has. The left auxiliary fan is set from the same speed popover as the others, and the enclosure fan is labelled **Exhaust** on the P2S and X2D — matching the printer's own screen and Bambu Studio — while every other enclosed model keeps **Chamber Fan**. The confirmation message after changing a speed uses the same name as the tile that was clicked. The four tiles are ordered part cooling, left auxiliary, auxiliary, exhaust, so they read left to right in the same order as the physical fans. Both fields are also published through the status endpoint, the WebSocket feed and the MQTT relay, so external automations can read them. Translated in all locales; wiki updated. Covered by backend and frontend tests, including the case where the printer sends a partial fan report — a tile must not disappear mid-print just because one update didn't mention it. - **Live print progress in the browser tab (#2693, contributor @Chachigo, requested in #1041)** — Watching a print meant keeping the Bambuddy tab in view, or switching back to it every few minutes. Enable **Print progress in tab** under Settings → Appearance and the tab title becomes `42% · Bambuddy` while the favicon turns into a progress ring in your theme accent colour, both updating live over the WebSocket the rest of the UI already uses. With several printers running, the tab follows the one finishing soonest, tie-broken by highest progress; title and favicon return to their defaults as soon as nothing is printing or the toggle goes off. Off by default, and stored per browser (like the light/dark toggle) so a wall-mounted dashboard and a laptop can each have their own setting. Translated in all locales; wiki updated. Covered by frontend tests. - **Telegram notifications can target a forum topic (#1518, reporter @vmhomelab)** — Telegram groups with Topics enabled always received Bambuddy's notifications in the **General** topic, because only Bot Token and Chat ID were configurable. Getting a per-printer split therefore meant creating a separate chat per printer. The Telegram provider now takes an optional **Forum Topic ID** — the last number in a topic's link (`t.me/c/1234567890/25`) — and routes its messages into that topic, so a single group can carry one topic per printer. Left empty, the behaviour is unchanged. The ID is sent on both the plain-text and the thumbnail code paths, and is validated as a number in the form and again server-side, so a typo is reported instead of silently breaking only text notifications. Translated in all locales; wiki updated. Covered by backend and frontend tests. - **Support bundles now record Bambuddy's own memory, threads and child processes (#2734)** — A bundle described everything except the thing it runs in. That made reports of memory climbing over days impossible to act on: the numbers that identify what is actually growing only exist while it is happening, and by the time anyone asked, the container had been restarted. Bundles now carry resident and virtual memory, thread count, child processes by name, open files and sockets, process uptime, and a census of live objects by type. Those figures separate causes that look identical from outside — a large virtual size against a modest resident one is address space rather than data, a rising thread count points somewhere quite different from a rising child-process count, and the object census names what a growing heap is filling up with. The object census is skipped on processes already above 2 GB, because walking the heap costs most on exactly the process that can least afford it; everything else is still collected. Child processes are recorded by executable name only — an ffmpeg command line carries the camera URL and its password. Collection happens off the main loop and every metric is best-effort, so a hardened kernel or restricted container that refuses one of them still produces a complete bundle. - **Folder rows in the File Manager now show when anything inside them last changed (#2680 follow-up, reporter @cadtoolbox)** — The calendar toggle only put dates on the file pane, so the folder tree had no way to show the timestamp it was already sorting on. Folders now render that value under their name whenever the toggle is on, nested folders included. It is labelled **last activity** rather than "last modified" deliberately: the value is the newest timestamp among the folder, its files and everything below it, so a folder can legitimately read as newer than its own directory mtime — calling that "modified" would look like a fresh instance of the `ls -lt` mismatch the issue was originally about. Folders with no activity render nothing rather than an `Invalid Date` placeholder. Frontend-only — the field was already on the wire from the sort fix. Covered by frontend tests. ### Changed - **Debug logs now record what the printer reports between the last layer and the end of a print (#2547, reporter @anthonyma94)** — The finish photo wants a moment that Bambu firmware does not obviously announce: printing done, toolhead parked, filament unload not yet started. Bambuddy has been driving that capture from `stg_cur=22` ("Filament unloading"), which turns out to fire on no model at all — across 247 support bundles there is not a single stage-22 capture, including the window in which it was the only trigger in the code, where all 104 captures on A1, A1 Mini, H2C, H2D, P1S, P2S, X1C and X2D fell through to the after-the-fact fallback. Choosing a replacement was not possible from the bundles we had, because outside `stg_cur` and `mc_print_sub_stage` every stage and action field the printers send is dropped unread, and the most promising candidates (`print_real_action`, `mc_action`, `mc_stage`) are absent from A1, A1 Mini and P1S payloads entirely. With debug logging enabled, Bambuddy now dumps those raw fields for the window between the last object layer and the end of the print — opening on the first end-of-print signal (last layer reached, progress at 99+, or no remaining time), logging only what changed frame to frame, and closing on the state transition — so a single debug bundle per model can show whether any firmware marks that moment. Diagnostics only: nothing reads these values, they are printer telemetry with nothing identifying in them, and at normal log levels the probe does no work at all. Covered by tests for the window boundaries, the frame budget and the guarantee that the probe cannot break status ingest. ### Fixed - **Bambu Cloud sign-in with a TOTP (authenticator app) account always failed with "Invalid code" (#2696, reporter @cmerkle)** — Every TOTP verification was rejected regardless of the code. Bambu Lab added double-submit CSRF protection to the `bambulab.com` web origin, which is where — and only where — Bambuddy posts the two-factor code; the endpoint refused the request with `403 CSRF error: missing_cookie` **before evaluating the code at all**, and Bambuddy surfaced that as "Invalid code". Reproduced against the live endpoint with a deliberately invalid key: a bare POST returns `missing_cookie`, `GET /api/csrf` mints a `bbl_csrf_token` cookie, a POST carrying only that cookie returns `missing_header`, and a POST carrying the cookie plus an `x-bbl-csrf-token` header reaches application logic. Bambuddy now performs that handshake before submitting the code. Note that landing on the sign-in page first — the intuitive fix — does **not** work: that page sets only Cloudflare's `__cf_bm`. **Also fixed:** a CSRF refusal no longer masquerades as a wrong code; it now says the code was never checked, so nobody else loses an evening to clock drift and leading-zero theories. Only TOTP sign-ins were affected — every other cloud call, including the email-code two-factor path, goes to `api.bambulab.com`, which is not gated, and existing stored tokens kept working throughout. Covered by tests that pin the exact header name and the origin used per region. - **A2L AMS filament showed as "?" in Bambu Studio through the Virtual Printer, and manual filament picks reverted (#2697, reporter @qoatzelcoat)** — Every slot of the A2L's AMS Lite rendered as an empty question mark in the slicer's Device tab while Bambuddy's own AMS card showed type, colour and spool correctly; setting a filament by hand in Studio held for a second and then snapped back to "?". **Root cause.** The A2L reports its AMS Lite as physical unit id 16, but packs the slots' presence bits at bit base 24 — so Bambuddy normalises the id to 6 at the MQTT ingest boundary and every internal reader gets the right bits. The Virtual Printer's bridge, however, parses the printer's raw payload itself (by design — the slicer-facing cache has to keep the physical ids, since Bambu Studio addresses the Lite as 16) and so still held id 16 when it ran the shared empty-slot cleanup. That cleanup read bits 64-67, where nothing is ever set, concluded all four slots were empty and wiped `tray_type`, `tray_color`, `tray_info_idx` and the RFID fields from the copy sent to the slicer — once per second, which is also why a manual pick could not survive. **Fix.** The presence-bit helper now folds the physical id 16 onto the same bit base as the normalised 6, so it computes bits 24-27 whichever id reaches it; the cached ids the slicer sees are left untouched. Only the A2L was affected — every other AMS type already reached the helper with an id whose bit base was correct, and Bambuddy's own printer card was correct throughout. Confirmed against the reporter's debug log, which shows the cleanup clearing slots at bits 64-67. Covered by tests pinning the bit base for both ids and a bridge-level regression test built from the reporter's capture. - **The Settings page no longer reverts settings changed from anywhere else (#2716, reporter @jmoore-skild)** — While the Settings page was open it held its own copy of every setting and only ever took one from the server, on first load. A background effect then compared that copy against the server's and saved the whole thing back on any difference — with no way to tell "the user edited this field" from "this field changed on the server". So anything written while the page sat open was silently undone: a change made in a second tab, another user's change on a shared install, a restore from a backup. It needed no click to trigger. The page's data goes stale after a minute and refreshes when the window regains focus, and around thirty other places in the app read the same settings, so a refresh from any of them was enough — after which the page wrote its page-load copy back over all 77 settings it manages, and showed **Settings saved** while doing it. The page now keeps track of the last server state it reconciled with. A field still matching that state has not been touched, so a newer value from the server is adopted and displayed; a field the user has edited keeps their value and is saved over the top, so the newer of the two writes wins either way. Typing into a text field while a refresh lands is still safe, which is what the old behaviour was protecting. Covered by frontend tests. - **A rejected K-profile write is now reported as rejected (#2718, reporter @jmoore-skild)** — Saving a K-profile was fire-and-forget: Bambuddy published the command and reported success the moment the bytes left the process. The printer does answer, and the answer was received, matched, and thrown away at debug level — so a write the printer refused for a real reason still told you it was saved. The complication was that the answer itself was wrong: on single-nozzle printers it came back `result: "fail", reason: "invalid tray_id"` on writes that demonstrably applied, which made gating on it look impossible. Measuring against an X1C and an H2D found the cause — the `tray_id: -1` Bambuddy itself put in the payload. The X1C's firmware validates that field and rejects the value while applying the write anyway; the H2D ignores it. Sending `0`, as BambuStudio does, makes the acknowledgement honest, and the printer echoes back the sequence number we sent, so it can be matched to the write that caused it. Saving or deleting a profile now waits for that answer and surfaces a genuine rejection as an error instead of a success toast. A printer that stays silent is still treated as success — no answer is not evidence of refusal. The acknowledgement is also logged at INFO now, so it appears in a support bundle. Covered by backend tests. - **The K-profile flow type is a real choice again** — On most printers the calibration table comes back with no nozzle identity at all, and Bambuddy had started showing "Not reported by printer" in the Flow Type field as a result. That is not a value you can save, and it isn't what the slicer does: BambuStudio treats a missing nozzle identity as **Standard** and leaves the choice editable. Bambuddy now does the same. The field is hidden only on models sold with a single nozzle variant — the A1, A1 Mini and A2L — using the same rule the slicer applies. This is not the single-versus-dual-nozzle split: the P1P, P1S, P2S, X1, X1 Carbon, X1E and H2S are all single-nozzle and all offer both flows. Editing a profile also no longer strips the nozzle identity from what it writes back. - **Dialogs no longer act after they have closed** — The AMS slot configuration and K-Profile dialogs hold their success state briefly and then close themselves, between 1.5 and 4 seconds after the command is sent so the printer has time to process it. That timer ran whether or not the dialog was still open, so dismissing it — or the printer card refreshing underneath it — within that window left a pending close that fired later, dismissing whatever dialog happened to be open by then. The deferred close is now cancelled when the dialog goes away. Covered by frontend tests. - **A printer with no K-profiles can now be given its first one (#2719, reporter @jmoore-skild)** — **Add K-Profile** built its Filament dropdown out of the profiles already on the printer, so on a printer with none the field was empty, required, and impossible to satisfy — the modal even said so, telling you to go and create the profile in Bambu Studio instead. The filament picker is now populated the way every other one in Bambuddy is, in the same order: **Imported** presets first, then **Orca Cloud**, then **Bambu Cloud**, then Bambuddy's built-in Bambu filament table. That last tier is compiled in, so the list is never empty — a brand-new printer with no cloud account and nothing imported still gets you a profile. The per-printer-model copies a cloud account carries ("Bambu PLA Basic" once for the X1C, once for the P1S, once for the A1) are collapsed into a single row, and the built-in table — a static copy of the same Bambu catalogue — no longer echoes back filaments the groups above already list. Your imported and Orca Cloud libraries are both shown in full even where they overlap by name, because they are usually the same profiles reached two ways and each group is worth seeing under its own heading. The picker is a searchable list with the source heading shown as a real, legible group header — a native dropdown can't do that, since browsers render the group label of a `` (populated from `api.getPrinters()`); pipelines without a target render an amber "Set a target printer to run this" hint in the row + a "Set a target printer before running this pipeline" warning at the bottom. Last-run summary appears inline per row — small `Last run: completed · 27/06/2026, 14:23` line driven by `GET /slicer-pipelines/{id}/runs?limit=1` with a 15 s `refetchInterval` so the chip ticks while a run is in flight. `RunStatusBadge` colour-codes the seven states. **New component `RunWithPipelineModal`** at `components/RunWithPipelineModal.tsx` — two-step dialog: step 1 lists the user's pipelines (each row shows the pinned target printer; pipelines without a target are disabled with a `No target printer set` hint), step 2 is the eligibility confirmation. Fast path: ok=true skips step 2 entirely and fires the run straight from the pipeline pick. Slow path: shows per-issue text via the `IssueText` mapper — eg. `Filament slot 1: expected PLA, AMS has PETG` for `filament_type_mismatch`, `AMS slot 2 not available on this printer` for `ams_slot_missing` — then `Run anyway` posts with `force=true`. **FileManagerPage integration**: FileCard's action menu picks up a `Run with pipeline` entry (gated on the new `pipelines:run` permission); list-view rows get a matching inline Play-icon button so list users have the same entry point as card users. Both flow into the same `setRunPipelineFile(file)` state which renders the modal. The action is only offered on slice-eligible files (3MF / STL / STEP) and only when `use_slicer_api` is on — matches the existing Slice button gating, since a non-slice-eligible file can't reach the slice step in any case. **Frontend types**: client.ts grows `PipelineEligibilityReport`, `PipelineRun`, `PipelineJob`, `PipelineRunListResponse`, plus six new `api.*` methods (`checkPipelineEligibility`, `runPipeline`, `listPipelineRuns`, `getPipelineRun`, `cancelPipelineRun`, and the updated `updateSlicerPipeline` which now accepts `target_kind` + `target_printer_id`). The `Permission` union also gets `pipelines:read | pipelines:write | pipelines:run` — these were on the backend Permission enum from PR A but had been missed in the frontend union (caught when TS rejected `hasPermission('pipelines:run')`). **i18n.** ~36 new keys across `library.runWithPipeline.*` (modal title / confirm / source-hint / pipeline-hint / target-hint / Run-anyway / 8 issue-kind strings / 2 toast / empty-state / no-target hint) and `settings.pipelines.field.targetPrinter` / `field.noTarget` / `noTargetHint` / `noTargetWarning` / `runs.lastRun` + seven `runs.status.*` strings — translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5473 leaves per locale, no English fallback. The string `slicing` was added to `IT_COGNATES` (genuine cognate — same word in Italian). **Tests.** 13 new backend integration cases in `test_pipeline_runs_api.py` covering PUT target write + clear-via-0 + check-eligibility (printer_not_set / printer_disabled cascade with offline / fully-clear AMS-match) + run flow (409 on issues+!force / 400 on force+!target / 202 on clean path with creation of run+job) + list/get 404s + cancel (404 / marks queued / idempotent on terminal). Slicing itself is stubbed via `patch(..._run_pipeline_orchestration)` so CI runs without a live sidecar. 4 new vitest cases in `RunWithPipelineModal.test.tsx` pin the modal's two-step flow: empty state, disabled pipeline-without-target, fast-path (issues empty → modal closes immediately after `runPipeline(..., false)`), slow-path (issues shown → `Run anyway` posts with `force=true`). **Suites.** `pytest -n 30 backend/tests/` 6530/6530 green; `npx vitest run` 2278/2278 green (172 files); `npm run build` clean; `python -m ruff check backend/` clean; `node scripts/check-i18n-parity.mjs` clean. **What's out of scope for PR B.** Multi-copy (`copies > 1`), class targeting (`target_kind='printer_class'`), fanout strategies, the Pipeline Runs dashboard — all PR C. Painted multi-filament 3MFs still hit the upstream OrcaSlicer CLI gate (OrcaSlicer/OrcaSlicer#13774); the slice step inside the pipeline run fails the same way the standalone slice route does, the run rolls up to `status='failed'` with the slicer's error string in `error_message`. The print queue's existing AMS / filament check + the printer-side error path remain authoritative for what actually happens at the machine — pipeline eligibility is a *pre-flight*, not a hard guard. - **Slicer Pipelines — save & reuse a preset bundle in one click (#1425 PR A, requested by @TheUltimateC0der)** — Top feature in the first sponsor vote. The SliceModal forces the user to pick four slots every time: printer / process / filament(s) / bed type. For fleet production that's tedious and error-prone — operators want a named "Production PLA" bundle they can apply with one click on every file and every printer. **PR A scope.** Definitions only. The new model `slicer_pipelines` materialises the bundle plus future-PR columns (`target_kind`, `target_printer_id`, `target_model_class`, `fanout_strategy`) so PR B (single-target dispatch) and PR C (multi-copy batch with capability-matched fanout) are code-only, not migrations. The bundle is independently useful in PR A as an ergonomic improvement: pipelines are picked from the SliceModal, applied to the four slots, then sliced through the existing flow. No new dispatch behaviour yet. **Backend.** Model `SlicerPipeline` (`models/slicer_pipeline.py`), Pydantic schemas `SlicerPipelineCreate` / `Update` / `Response` reusing the existing `PresetRef` shape from `schemas/slicer.py`, CRUD routes at `/api/v1/slicer-pipelines/` (`GET list`, `POST create`, `GET/PUT/DELETE by id`). Soft-delete via `is_deleted` so PR B+ run history can still resolve pipeline metadata after the operator removes one. Listed newest-first by `id DESC` (more reliable than `created_at` under back-to-back inserts whose DateTime precision can tie). Routes use explicit `await db.commit()` after the mutation (matches the `routes/library.py` pattern) so the response shape returns the committed row. **Permissions.** Three new `Permission` values: `PIPELINES_READ`, `PIPELINES_WRITE`, `PIPELINES_RUN`. PR A only consumes the first two; `RUN` is defined now so PR C doesn't need to backfill. `Administrators` and `Operators` get all three; `Viewers` get `PIPELINES_READ`. A backfill block in `seed_default_groups()` adds them to existing groups on upgrade (mirrors the `library:purge` / `archives:purge` pattern from earlier). All three are added to `_APIKEY_DENIED_PERMISSIONS` so they fail closed for any API-key surface — PR B / PR C may move `PIPELINES_RUN` onto `can_queue` once the dispatch lands. **Frontend.** Settings → Workflow tab is split into two sub-tabs mirroring the Authentication tab's pattern: **Queue & Dispatch** (the existing Workflow content) and **Pipelines** (the new manager). The Workflow sidebar entry stays single — no expandable submenu — and the sub-tab choice is reflected in the URL (`?tab=queue&sub=pipelines`) for deep-linking. **SlicerPipelinesPanel** lists saved pipelines with inline rename, soft-delete, and a stale-preset warning when a referenced preset no longer resolves against the unified-presets listing (e.g. an `orca_cloud` preset deleted in OrcaSlicer; the pipeline still saves, the warning prompts a re-save from the SliceModal). Full pipeline creation lives in the **SliceModal** rather than Settings — the user has already done the four-slot work there. The modal grows an `Apply pipeline ▾` dropdown plus a `Save as pipeline` button above the existing preset dropdowns. Apply fills all four slot states (`printerPreset`, `processPreset`, `bedType`, `filamentPresets[]`); the filament list right-pads from current state so a pipeline with fewer entries than the current source's slot count keeps the existing tail (lets the same pipeline apply across single-color and multi-color files). Save captures the four-slot picks under an inline-named pipeline. Stale-preset warning shows on the Settings list, not blocking apply, so an old pipeline with a one-deleted-preset can still be re-applied and re-saved with the new pick. **i18n.** ~30 new keys across `settings.pipelines.*` and `slice.pipelines.*` plus `settings.tabs.queueDispatch` / `queuePipelines`, translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5437 leaves per locale, no English fallback. `Pipeline` / `Pipelines` / `Filament {{n}}` added to `IDENTICAL_TO_EN_ALLOWED` for the locales where they're genuine cognates (de / es / fr / it / pt-BR / tr). **Tests.** Backend: 11 integration cases in `test_slicer_pipelines_api.py` covering empty list, create + round-trip, get-by-id, partial PUT preserves untouched fields, filament list replaces wholesale, soft-delete hides from list + GET-by-id, 404s on missing, schema rejection of empty filament list + invalid PresetRef source, newest-first ordering. Frontend: 3 new SliceModal cases (apply-pipeline dropdown disabled-empty / apply-sets-state / save-as-pipeline-round-trip) plus 2 SettingsPage cases (sub-tab nav renders + Pipelines deep-link). Existing SliceModal tests adjusted via a `presetSelects()` helper that filters out the new Apply-pipeline combobox so historical `selects[0]` indexing into printer/process/filament remains stable. **Suites.** `pytest -n 30 backend/tests/` 6517/6517 green; `npx vitest run` 2274/2274 green (171 files); `npm run build` clean; `python -m ruff check backend/` clean; `node scripts/check-i18n-parity.mjs` clean. **Scope.** No new dispatch behaviour yet — pipelines are a preset-bundle convenience layer in PR A. PR B adds single-target dispatch (the `target_kind='specific_printer'` path), PR C adds multi-copy batch with capability matching + the three fanout strategies (`max_parallel` / `fill_one_first` / `round_robin`). The `Run pipeline` action mentioned in the original issue is PR B/C and intentionally not exposed in this drop. Painted multi-filament 3MFs still hit the upstream OrcaSlicer CLI gate (`OrcaSlicer/OrcaSlicer#13774`); the slice fails, the pipeline doesn't pre-validate. - **Sticky upload-progress toast restored for scheduler-driven dispatch (#1625 follow-up)** — `#1625` (`Unify print dispatch through the scheduler`) moved every print's FTP push to the printer into the server-side scheduler tick, which means the user's click no longer carries an XHR with `progress` events — the old browser-side upload modal had nothing to show because there was no browser-side upload anymore. Users only saw the queue item flip to "active" with no visibility into the multi-second to multi-minute FTP push + the H2D/H2D Pro 80–210 s `project_file` digestion window before the printer actually started extruding. **Fix.** The legacy bg-dispatch toast rendering from `0b43ac0d:frontend/src/contexts/ToastContext.tsx` lines 510–650 is **ported back in place verbatim** — same DOM tree, same Tailwind classes, same `formatFileSize` bytes line, same uppercase status chip, same collapse chevron, same `awaitingPrinter` derivation, same auto-dismiss-when-all-terminal — only adapted to read from the four scheduler-side WS events introduced here instead of the legacy `background-dispatch` aggregate event. **Materialization only on actual upload start.** The toast appears when the FTP push to the printer starts (`queue_item_uploading`), NOT on `POST /queue` — a draft that emitted at queue-add time made the toast jump to "Dispatched" before any upload had happened. Four backend lifecycle WS events drive the rendering: `queue_item_uploading` (start of FTP, carries `printer_name` + `total_bytes` from `file_path.stat().st_size`), `queue_item_upload_progress` (throttled byte-level updates — first call always emits + emit when ≥200 ms elapsed OR ≥256 KB transferred since last emit, plus always emit at `bytes_transferred >= total_bytes`; this matches the legacy `background_dispatch.py:614-615` gates 1:1 so the bar feels identical on small AND large files; a single shared `_UploadProgressBridge` instance bridges from the FTP executor thread back to the asyncio loop via `run_coroutine_threadsafe`), `queue_item_acked` (watchdog confirmed printer transitioned out of `pre_state`), `queue_item_failed` (any error, with a `reason` key the toast looks up as `dispatchToast.failed.{reason}` for upload-vs-start-command differentiation, generic fallback). **No `queue_item_dispatched` event** — the legacy bg-dispatch path kept `status='processing'` from upload start until printer ack, and the "Awaiting printer…" subtitle is derived purely from `upload_progress_pct >= 99.9` (the legacy `uploadDoneAwaitingPrinter` trick at line 568-572). An explicit `dispatched` event would push the status chip out of `PROCESSING` prematurely — which is exactly what the first screenshot-iteration showed. **Per-user routing.** New `ws_manager.broadcast_to_user(user_id, msg)` filters connections by `websocket.state.bambuddy_principal_user_id` — resolved once at WS connect time via a `select(User.id).where(User.username == principal)` lookup so per-message routing is O(connections) not O(connections × DB). Auth-disabled installs route `user_id=None` to all connections, matching the legacy single-user toast behaviour. The watchdog success path receives `created_by_id` via a new kwarg so the static `_watchdog_print_start` method can still emit the `acked` event without re-fetching the queue item. **Backend.** ~110 LOC across 3 files: `core/websocket.py` (`broadcast_to_user` + four event helpers, `bambuddy_principal_user_id` filter on each connection), `api/routes/websocket.py` (principal username → User.id resolve at connect, stashed on `websocket.state.bambuddy_principal_user_id`), `services/print_scheduler.py` (`_UploadProgressBridge` thread-safe throttle class, `queue_item_uploading` emitted before FTP with `printer.name`, `progress_callback=` plumbed into both the `with_ftp_retry` and direct `upload_file_async` branches via `**kwargs`, `queue_item_failed` at the FTP-fail spot, watchdog success path emits `acked` on both Phase A and Phase B exits). **Frontend.** Rendering ported in place to `contexts/ToastContext.tsx` (`dispatchData` field on `Toast`, ingest `useEffect` mapping the four `bambuddy:dispatch-toast` event types to legacy `DispatchToastJob` shape, terminal-state auto-dismiss `useEffect`; legacy rendering block reused 1:1 minus the cancel button — BG dispatch's `/background-dispatch/{id}` DELETE doesn't exist in the scheduler model and adding it is out of scope). `hooks/useWebSocket.ts` forwards the four `queue_item_*` cases via `window.dispatchEvent(new CustomEvent('bambuddy:dispatch-toast', { detail }))`, matching the existing `plate-not-empty` / `unknown-tag` patterns. **i18n.** 11 keys × 11 locales under `dispatchToast` (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW): `untitled` / `startingPrints` / `progressSummary` (header `{{complete}}/{{total}} complete • Processing: {{processing}}` — `Dispatched: X` from the legacy summary was dropped because the scheduler has no pre-upload "dispatched" state) / `expandDetails` / `collapseDetails` / `awaitingPrinter` / `status.{processing|completed|failed}` / `failed.{generic|upload_failed|start_command_failed}` / `dismiss`. Locale parity check 5401 leaves per locale, no English fallback. **Tests.** Backend `test_ws_broadcast_to_user.py` pins the routing contract (filter by user_id, fan-out on None, payload shape with `printer_name` for `uploading`, server-side pct compute including divide-by-zero); `test_upload_progress_bridge.py` pins the throttle (first call always emits, 256 KB byte gate honoured even when time gate would skip, completion always emits, no-op on zero bytes, no-op when no loop). Frontend `__tests__/contexts/DispatchToastContext.test.tsx` pins the **materialization-on-uploading invariant** (stray progress / acked event before any `uploading` does NOT render — regression guard), the uploading → "Awaiting printer…" → acked lifecycle with status chip staying `PROCESSING` through the whole upload (regression guard for the screenshot-reported "Dispatched: 1 immediately" bug), 3.5 s auto-dismiss when terminal, concurrent jobs sharing one wrapper, collapse + dismiss buttons. **Suites.** `pytest -n 30 backend/tests/unit/test_ws_broadcast_to_user.py backend/tests/unit/test_upload_progress_bridge.py backend/tests/integration/test_print_queue_api.py` green; `vitest run src/__tests__/contexts/` 49/49 green; `ruff check backend/` clean; `npm run build` clean. **Scope.** No DB migration. No new permission. The `bambuddy:dispatch-toast` window event is internal to the frontend bundle, not a public hook — third-party plugins should not subscribe to it. The 0–30 s scheduler-tick pickup wait is unchanged; this fix only addresses *visibility* of what happens once the upload starts. Tiny test files that upload in a single FTP chunk will still jump straight to "Awaiting printer…" because the first-and-last progress callback is one and the same event — same edge as the legacy bg-dispatch behaviour on sub-256 KB files. - **Cam Wall: don't kill shared streams when one viewer closes + offline tiles show OFF, not LIVE** — Two small but load-bearing fixes against the new cam-wall view. **(1) Offline tile chip.** A disconnected printer (`status.connected === false`) was still assigned `live` mode by `CameraWall.modeByPrinter` — it consumed a `Max live streams` budget slot AND rendered the red `LIVE` chip on top of the `WifiOff` placeholder. The allocator now treats `!connected` like off-screen — assigns `paused`, leaves the live budget intact. The existing `CameraTile` rendering (`WifiOff` icon, dark `Off` chip) takes over automatically. Side effect: an 8-printer wall with 2 offline X1Cs no longer wastes 2 of the 4 default live slots on dead tiles. **(2) Shared-broadcaster teardown.** `/api/v1/printers/{id}/camera/stop` is the unmount cleanup for every camera consumer (`CameraTile`, `EmbeddedCameraViewer`, popup `CameraPage`). It used to unconditionally `shutdown_broadcaster(f"printer-{id}")` + kill every ffmpeg in `_active_streams` whose key starts with `{printer_id}-`. The fan-out broadcaster is shared across all viewers of the same printer, so closing the embedded viewer while the cam-wall tile of the same printer was visible force-killed the source the tile was pulling from — the tile's `` errored out and showed `No signal` until the user navigated away. The broadcaster itself already has correct natural-shutdown semantics: each subscriber's HTTP teardown calls `unsubscribe(queue)`, and when the count reaches 0 the broadcaster's own `_grace_then_stop` waits `_GRACE_SECONDS` (5 s) before tearing down — re-checking under the lock so a new subscriber rejoining cancels the shutdown. `/camera/stop` was just a fast-cleanup shortcut for the single-viewer case. **Fix.** New `get_subscriber_count(key)` accessor in `camera_fanout.py` exposes the broadcaster's `subscriber_count` (the private list-len already used internally). The `/camera/stop` route now reads `get_subscriber_count(f"printer-{printer_id}")` BEFORE the force-teardown; when ≥ 1 subscriber is still attached, it returns `{"stopped": 0, "skipped": true}` early and leaves the broadcaster + ffmpeg processes alone. The leaving viewer's HTTP teardown still runs the natural `iter_subscriber.finally → unsubscribe` path, so its subscription is correctly released; the broadcaster keeps serving the other viewer(s). Single-viewer close still hits the force-teardown path immediately (no subscribers remain at all). Cost: in the race where the leaving viewer's HTTP teardown has already propagated to the broadcaster at the moment its `/camera/stop` POST lands (count just dropped to 0), force-teardown still runs and we miss the optimization for a different actually-still-subscribed viewer — but the natural grace-shutdown bounds the worst case at 5 s of ffmpeg tail, not a stuck stream. Verified by inspection: this race only matters when subscriber_count transitions through 0 between the HTTP teardown and the POST, which requires both viewers' tabs to close in lockstep — practically unobservable. **Tests.** New `test_stop_camera_stream_skips_shutdown_when_subscribers_remain` in `test_camera_api.py` patches `get_subscriber_count` to return 2 and asserts `/camera/stop` returns `{stopped: 0, skipped: true}`, does NOT call `shutdown_broadcaster`, and does NOT terminate any `_active_streams` ffmpeg process. The existing 6 stop-route tests stay green because they don't pre-populate subscribers — `get_subscriber_count` returns 0, the early-return doesn't trigger, and the existing force-teardown still runs. Full `test_camera_api.py` 43/43 green. `ruff check backend/` clean. Frontend `npm run build` clean. **Scope.** No API contract change — the existing `{"stopped": int}` shape is preserved, the new `"skipped"` field is additive. No new permission. No DB migration. No i18n change. - **Cam Wall: per-tile print/printer status overlay** — Cam-wall tiles now surface live printer state on top of the camera image instead of being a pure video grid. A new gear-menu toggle `Status overlay` switches between `Off`, `Compact`, and `Full` (default `Full`). **Compact** paints a colour-coded state chip in the top-left corner — `Printing` / `Paused` / `Finished` / `Error` — bucketed using the same `classifyPrinterStatus` rules that drive the printer-card badges, with `Idle` deliberately suppressed so a wall of cold printers stays visually quiet. **Full** adds a bottom info strip on tiles whose state is `Printing` or `Paused`: the active file's `subtask_name ?? gcode_file`, the rounded progress percent, `Layer N/M` when both are known, and the remaining time formatted by the existing `formatDuration(remaining_time * 60)` helper from `utils/date.ts` — so the numbers match what the printer card shows for the same printer. When the printer's known HMS errors are non-empty (filtered via the existing `filterKnownHMSErrors` from `HMSErrorModal`), the chip flips to the red `Error` colour with a `lucide-react` `AlertTriangle` icon inline. The whole overlay layer is gated by `connected` — disconnected and paused-mode tiles render the existing offline / paused placeholders unchanged. **Zero new network cost.** `CameraWall.tsx` already ran `useQueries({ queryKey: ['printerStatus', id], ... })` against every printer for the connected flag; the patch widens the `useMemo` to expose the full `PrinterStatus` payload and threads `state`, `progress`, `remaining_time`, `layer_num`, `total_layers`, `subtask_name`, `gcode_file`, and the filtered HMS error count into each `CameraTile` — same shared React Query cache the `PrinterCard` flow populates, so Cards ↔ Cam Wall flips remain instant and the wall opens no second status fan-out. **Settings.** Per-user, persisted in `localStorage` under `camWallStatusMode` alongside the existing `camWallMaxLive` and `camWallSnapshotSec` keys. The picker is a three-segment button row inside the existing cam-wall settings popover (gear icon, click-outside dismiss), labelled `Off` / `Compact` / `Full`. Default `Full` because the cards already show this info — users who pick cam-wall view still want to glance the same details without flipping back. **CameraTile contract.** All new props (`statusMode`, `printerState`, `progress`, `remainingMin`, `layerNum`, `totalLayers`, `printName`, `hmsErrorCount`) are optional with safe defaults, so the 5 existing vitest cases in `CameraTile.test.tsx` continue to pass unmodified — the status layer is purely additive on the leaf component. The state-bucket classifier lives co-located in `CameraTile.tsx` (mirrors `PrintersPage.classifyPrinterStatus` for `RUNNING/PAUSE/FINISH/FAILED`) so the tile renders correctly even if called outside the cam-wall scheduler. **Temperatures intentionally not surfaced.** Nozzle / bed / chamber readouts would crowd the tile and overlap the existing top-right LIVE/SNAP/OFF mode indicator and bottom-edge printer name; the printer card remains the canonical surface for those. **i18n.** 7 new keys under `printers.camWall` (`layer`, `timeLeft`, `statusMode.{off,compact,full}`, `settings.statusOverlay`, `settings.statusOverlayHint`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) — no English fallback. State chip labels reuse the existing `printers.status.{printing,paused,finished,error,idle}` keys so no new translation work was needed for the bucket vocabulary. Parity script `check-i18n-parity.mjs` adds two legitimate-cognate exceptions: `Compact` for French (same word) and `Off` for Italian (universal loanword); both remain real translations in every other locale. Parity check 5388 leaves per locale. **Scope.** No backend change. No new request. No new permission. No DB migration. The toggle defaults to `Full`, so installs see the overlay the first time they open Cam Wall — flipping to `Off` reverts to the original camera-only behaviour. - **Cam Wall view on the Printers page** — New view toggle next to the card-size selector flips the entire printers list into a responsive grid of live camera tiles (`Cards` ↔ `Cam wall`). Reuses the existing per-printer FTP / RTSPS proxy on `/api/v1/printers/{id}/camera/stream`, so the backend ffmpeg fan-out is the same one EmbeddedCameraViewer already drives — no new server-side state machine. Bandwidth ceiling matters on the RPi installs ([[bambuddy-install-base-2026-06-20]] documents that the median deployment is a Pi 4): each live tile is one TLS pull + one MJPEG fan-out. To stay sustainable on a Pi 4 with 8+ printers, only the tiles currently on-screen are live, and only up to `Max live streams` (default 4) at any moment — everything else falls back to per-tile snapshot polling against `/api/v1/printers/{id}/camera/snapshot` at a configurable interval (default 8 s). Tiles that scroll off-screen pause entirely. **Architecture.** `frontend/src/components/CameraTile.tsx` is the leaf — three modes (`live` / `snapshot` / `paused`), a single `` element with `loading="lazy"`, an `onError` no-signal fallback, and a `useEffect` cleanup that POSTs `/camera/stop` (with `keepalive: true`) on mode-out-of-live AND on unmount so the backend releases the transcoder slot. Same `/camera/stop` discipline EmbeddedCameraViewer uses, so a tile that scrolls off the wall is byte-identical to closing a floating viewer. `frontend/src/components/CameraWall.tsx` is the scheduler — an `IntersectionObserver` (threshold 0.4 to avoid flicker at scroll boundaries) tracks visibility, then a `useMemo` walks the printer list in sort order and assigns the first N visible tiles to `live`, the rest of the visible set to `snapshot`, and off-screen tiles to `paused`. The walker is stable on a given render (no LRU eviction churn) which avoids the "tile flickers between live and snapshot every frame" failure mode. Reuses the same `['printerStatus', id]` React Query cache each `PrinterCard` already populates, so flipping between Cards and Cam Wall is instant and the wall doesn't open a second status fetch fan-out. Clicking a tile honours the existing `Settings → camera_view_mode` preference — opens the floating `EmbeddedCameraViewer` when set to `embedded`, otherwise pops the `/camera/:id` window with the saved size/position from `cameraWindowState`. **Settings.** Both knobs are per-user, persisted in `localStorage` (`camWallMaxLive`, `camWallSnapshotSec`) — not a global backend setting, since a Pi 4 user and a NUC user looking at the same install want different caps. Bounded `[1, 16]` for max live and `[2, 60]` seconds for snapshot interval, both rendered as an inline gear-icon popover above the grid with click-outside dismiss. The Cam Wall button is permission-gated on `camera:view`; viewers without the permission see it disabled. The card-size selector goes opacity-40 + pointer-events-none in cam-wall mode (tile size is governed by the responsive grid, not the cardSize knob). **i18n.** 13 new keys (`printers.pageView.cards`, `printers.pageView.camWall`, `printers.camWall.{noPrinters,noSignal,live,snap,off,summary}`, `printers.camWall.settings.{title,maxLive,maxLiveHint,snapshotInterval,snapshotIntervalHint}`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) — no English fallback. Parity check 5369 leaves per locale. **Tests.** 5 new vitest cases in `frontend/src/__tests__/components/CameraTile.test.tsx` cover live URL emission with `fps=8`, snapshot URL emission with the cache-bust counter advancing on the interval, offline placeholder for disconnected printers, paused placeholder rendering, and the `/camera/stop` POST firing when the tile transitions out of live. **Scope.** No backend change. No DB migration. No new permission. The existing `EmbeddedCameraViewer` is untouched — Cam Wall is purely additive. The `printerPageView` toggle defaults to `cards`, so installs see no behaviour change until a user picks Cam Wall. - **AMS drying badge now shows the active cycle's filament + target temperature** — During an active drying cycle the AMS card on the printers page renders `Drying · PETG @ 65°C · 11h 35m left` (the loaded-filament line under the slots) instead of the bare `Drying · 11h 35m left`. Bambu's per-tick AMS push only carries the `dry_time` countdown — the chosen filament name and target temperature are never echoed on the wire, so the badge had no source of truth for them. `BambuMQTTClient.send_drying_command(mode=1, ...)` now caches `{ams_id: {filament, temp}}` on the client; the cache is cleared on `mode=0` and on the per-AMS `dry_time` falling-edge to 0 (same detector that drives the smart-plug-after-drying callback). `PrinterManager.get_drying_targets(printer_id)` exposes it, `printer_state_to_dict` and `routes/printers.py::get_printer_status` thread it onto each AMS dict as `dry_target_temp` + `dry_filament`, the AMS schema gains both fields, and the AMS-HT compact badge gets the same render. Falls back to the first loaded tray's `tray_type` + RFID-recommended `drying_temp` when no cached target (drying started before backend launch, backend restarted mid-cycle, or cycle started from another source) — the same heuristic the popover already uses to seed defaults. New i18n key `printers.drying.targetSummary` = `{{filament}} @ {{temp}}°C`, translated in all 11 locales (parity check 5356 leaves per locale). 5 new backend tests in `TestSupportsDryingCommand` (cache populated on mode=1, overwrite on second start, cleared on mode=0, per-AMS isolation across stop) and 4 new tests in `TestDryingTargetExposure` (cached target wins over fallback, fallback derives from loaded tray, both fields None when no cache + empty trays, targets don't leak across AMS ids). **Note about Bambu's printer display.** A user reported that with PLA loaded in AMS-A slot 1 and a Bambuddy-initiated PETG @ 65°C drying cycle, the H2D's own screen showed "PLA" — Bambuddy's wire payload was confirmed correct via journalctl (`filament: "PETG"` sent, `result: success, filament: PETG, temp: 65` ACKed back). The display behaviour is the Bambu firmware labelling the active cycle by the loaded tray's filament rather than the `filament` field of the command. This Bambuddy change makes our own UI reflect what we actually sent, independent of the firmware's display choice. - **Continue auto-drying while a print is running on capable hardware** — Bambu shipped "Print While Drying" firmware-side on H2D (01.03.00.00+), H2C / H2S / P2S / H2D Pro (01.02.00.00+), X2D / A2L (01.01.00.00+), and X1C (01.11.02.00+). The existing Queue Auto-Drying loop only fires on idle printers — when a print starts, drying stops or never starts, even though the spools may still be wet. New **Settings → Print Queue → "Continue drying while printing"** toggle (default OFF) lets the same scheduler evaluator also run on the *busy* printer set. Backend: `supports_drying_while_printing(model, firmware)` in `printer_manager.py` is a strict allowlist verified against Bambu's wiki release-notes phrasing ("printing while filament is drying" / "Print While Drying" — every matrix-confirmed model carries that wording verbatim; **P1P / P1S / A1 / A1 Mini / X1 (non-C) / X1E are intentionally excluded** because the wiki is silent for them, and on those models the firmware would reject the command anyway via `dry_sf_reason=[0]` (TaskOccupied)). The capability is gated on both display names (`"H2D"`, `"X1C"`, ...) and internal SSDP / MQTT model codes (`"O1D"`, `"O1E"`, `"O2D"`, `"O1C"`, `"O1C2"`, `"O1S"`, `"N6"`, `"BL-P001"`, `"N7"`, `"N9"`) — the printer's `model` field can carry either, the existing `supports_drying` precedent uses both. `_check_auto_drying` in `print_scheduler.py` now resolves model + firmware up front for every printer and computes `mid_print = busy AND toggle_on AND supports_drying_while_printing`; when `mid_print` is True the busy-skip, queue-only-skip, and idle-skip gates are bypassed and the existing humidity / `dry_sf_reason` / drying-presets / mode-1 send path takes over. **Safety: drying temp is capped at `max(40, preset_temp - 5)` for mid-print drying** — Bambu's own release notes for H2D and P2S spell out "Lower drying temperature during printing" / "The drying temperature must not exceed the filament's softening temperature", so a 5 degC offset from the idle preset (floor 40) protects spools inside a hot enclosure during an active print. The early-return guard that short-circuits the evaluator when "only queue mode is on AND nothing scheduled" was also extended to skip the short-circuit when `print_drying_enabled` is on — otherwise busy printers would never be reached. The manual drying button on the AMS card needs no UI change: `routes/printers.py::start_drying` has no Bambuddy-side `is_idle` gate; the "printer busy" rejection comes from firmware `dry_sf_reason=[0]`, which simply won't appear on supported firmware mid-print. The new capability flag is also surfaced on `PrinterStatus.supports_drying_while_printing` so the frontend can light up the AMS card affordances correctly. **Settings.** New `print_drying_enabled: bool = False` in `schemas/settings.py`, added to the boolean allowlist in `routes/settings.py` (`_BOOL_KEYS`), and threaded through the existing dirty-detection / save call in `SettingsPage.tsx`. **i18n.** 2 new keys (`settings.printDryingEnabled`, `settings.printDryingEnabledDescription`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5354 leaves per locale, no English fallback. **Tests.** 7 new cases in `TestSupportsDryingWhilePrinting` cover every supported display name + internal code, below-min firmware, excluded models (`P1*`, `A1`, `A1 MINI`, `X1`, `X1E`), missing firmware, `None` model, case-insensitivity, and the strict unknown-model default (False — unlike `supports_drying` which leniently allows unknowns). 4 new scheduler integration cases in `TestMidPrintDrying` cover: toggle ON + capable hardware fires drying at the 40 degC cap for PLA, PETG caps to 60, toggle OFF still skips busy printers, and toggle ON with too-old firmware / excluded model still skips. Full `pytest -n 30` green (4251/4251 in 49 s). Backend `ruff` clean. Frontend `npm run build` clean. **Scope.** No DB migration. No new permission. The new toggle is opt-in (default OFF) — existing installs see no behaviour change until a user enables it, and the firmware is the ultimate arbiter via `dry_sf_reason` so being too permissive here costs nothing. - **Batch / mass edit on the Filament tab (#1795, requested by @RoBoT24-web)** — Bulk operations land on the Inventory page in both built-in and Spoolman modes. Reporter wanted "ten of the same spool, set a pressure advance value, save once" — the existing flow forced ten round-trips through the per-spool editor. **Frontend.** A new checkbox column anchors the leftmost slot of every row in the table view (header checkbox toggles every visible row; group rows expose a single checkbox that selects every member). As soon as one row is selected, a sticky toolbar appears above the list with **Edit / Print labels / Reset usage / Archive (or Restore in the Archived tab) / Delete / Clear selection**. The selection clears automatically on any filter or tab change so the toolbar count can never drift from what's on screen. A new `BulkEditSpoolsModal` is the entry point for the bulk-edit action: a three-state-per-field form (untouched / set-to-value) over the flat spool attributes — material, subtype, brand, color name + RGBA, storage location, slicer filament name + ID, cost / kg, note, label weight, core weight, category, low-stock threshold %. The reporter's pressure-advance use case (K-profile) stays per-spool because K-profiles are scoped per `(printer, extruder, nozzle_diameter)` and bulk-applying a single K-value across heterogeneous printers would create wrong calibration — they're handled in the existing per-spool K-profile editor instead. **Clearing fields in bulk is intentionally NOT supported** (user decision on #1795): bulk-set lets you only WRITE non-empty values; emptying ten notes by mistake is a one-click disaster the dialog doesn't expose. The per-spool editor remains the path for clearing. **Same dropdown controls the per-spool editor uses.** Material, sub-type, brand, category, slicer preset name, and slicer filament are all rendered through a new `SearchableSelect` component matching the per-spool form's pattern (text input + chevron + filtered list of buttons, click-outside + Escape close). No native `` filters on the runs dashboard with themed dropdowns and fixes a `react-hooks/exhaustive-deps` warning. ## [0.2.4.8] - 2026-06-28 ### Added - **Lower sponsor-prompt thresholds so the toast fires for typical new installs** (`257b9e2c`) - **SSO autologin + disable local username/password login (#1589, requested by @einstux)** (`549d3216`) - **"Auto-add unknown RFID spools" toggle + global confirmation modal (#1764)** (`9f9c1775`) - **Backup-aware filament deficit check, colour-strict (#1762)** (`29a5abd9`) - **Drag-reorder for grouped queue items; collapsed batches no longer block adjacent rows** (`7b06ebd7`) - **Per-filament humidity threshold for auto-drying + alarms (#1605, requested by @thenewguy)** (`7e5eff14`) - **Per-printer Maintenance Mode toggle (#1476, requested by @IndividualGhost1905 / Ferdi SEVER)** (`ee270922`) - **In-app sponsor-toast at earned milestones** (`e761e092`) - **Prominent sponsor banner on Settings → General** (`5c16ef3e`) - **Heater history (nozzle / bed / chamber) tracked + per-tile chart-icon overlay opens history modal** (`d1d16659`) - **AMS Filament Backup status badge + toggle on the printer card; "Prefer lowest" actually picks the lowest spool (#1766, reported by @biduleman)** (`99c6949b`) - **Updated printer card UI for structure and readability (#1661)** (`6fa74be4`) ### Changed - **Printer card AMS row: external tray height matches regular AMS slots** (`00e4aed7`) ### Fixed - **Assign-spool picker note now visible on mobile (#793 follow-up, reporter @EmcetPL)** (`b2b04fc4`) - **API keys with Manage Library permission can rename / delete / move library files (#1832, reporter @MorganMLGman)** (`4c795636`) - **Forecasting groups spools by colour + Forecast UI rework (#1814, by @Keybored02)** (`3cbdba0c`) - **False-positive "Print Stopped" notification on reprint after MQTT reconnect (#1807, reported by @volodymyr-doba)** (`5e008744`) - **Unknown-tag modal no longer pops for slots with no RFID** (`8b72b305`) - **SpoolBuddy "Assign to AMS" preserves the user's slicer preset instead of pushing Generic (#1815, reported by @Bgabor997)** (`cd5a02c0`) - **H2S active-tray highlight no longer stuck on AMS slot 1 during external-spool prints (#1822, reported by @ojimpo)** (`2bd2bce3`) - **`require_previous_success` no longer permanently blocks a printer's queue after a failure (#1818, reported by @jmassardo)** (`ba7af59b`) - **Archives "Step 4" docs link no longer 404s (#1812, reported by @Spanholz)** (`c52aba66`) - **H2C nozzle pick from Bambu Studio preserved on dual-nozzle rack variant + VP slicer-field intake (#1780, reported by @mkoreen)** — Race window bumped to 5s with retroactive stamp; VP intake key mismatch corrected; rack-swap nozzle pick forwarded to dispatch. (`71b0575f`, `30c2e263`, `b6916055`) - **Connection diagnostic no longer reports false camera-port warning on A1 / A1 Mini / P1 (#1799 closing #1798, by @lesbass / Stefano Maffeis)** (`8e99b0c8`) - **Print-complete notification no longer drops the finish photo when the FINISH-state fallback fires (#1790, reported by @needo37)** (`a9bf6f1f`) - **Chamber-fan badge hidden on open-frame Bambu printers** (`568f220a`) - **In-app "Install Update" on Windows installer switched to release-asset update flow** (`b7ff72d8`) - **Mid-print AMS Backup spool-switch correctly splits weight instead of crediting all to the second spool (#1771, reported by @biduleman)** (`a70c2a2d`) - **AMS history modal respects theme background variant in stats modal** (`55510871`) - **Completion notification scoped to printed plate on multi-plate 3MFs (#1785)** (`964015de`) - **Docker installer escalates on EACCES instead of failing on `/opt/bambuddy` (#1774, reported by @jmoore-skild)** (`03d09238`) - **Archive thumbnails rendered server-side when the sidecar slice skips them (#1759, reported by @VID-PRO)** (`d2232e02`) - **Post-#1661 printer-card cleanup — test fixtures + hover-card fly-in removal** (`0128869d`) - **Local Presets page: deleted row optimistically removed instead of staying visible until refetch returned** (`0eed9865`) - **SpoolBuddy inventory search matches spool ID, slicer filament name, and storage location (#1738, reported by @shaddowlink)** (`355d08a8`) - **Sidebar entries for Files / Archives / Queue no longer hidden from non-admin users with granular `*_read` access (#1755, reported by @knifesk)** (`7f150886`) - **Push notification for "Printer offline" actually fires (#1752, reported by @saint-hh)** (`2cbbd1ee`) - **Auth preserves the original URL across login + OIDC round-trip (#1750)** (`ed1683fe`) - **Archives backfill NULL `created_at` + tolerate NULL in response (#1732)** (`8faaeb96`) ### Security - **Floor pins for pydantic-settings ≥2.14.2 + msgpack ≥1.2.1** (`458bfa15`) - **Backend dependency security floor bumps + 422 constant rename** (`580f42c1`) - **dompurify 3.4.10 → 3.4.11 (GHSA-cmwh-pvxp-8882, moderate)** (`11227f65`) - **Vite 7 → 8 + plugin-react 5.2 (major bump)** (`f7620406`) - **Frontend dependency bumps** (`000af683`) - **Printer secrets restricted to update-authority callers** (`8283b175`) ## [0.2.4.7] - 2026-06-14 ### Added - **Bambu Lab A2L support (#1684)** — Internal model code `N9`, serial prefix `26A19` (5 chars, same shape as H2C's late `31B8B`). Capabilities resolved from BambuStudio's `resources/profiles/BBL/machine/Bambu Lab A2L.json` cross-checked against Bambu's official A2L specs page: linear rail, single FDM extruder + integrated cutter/plotter head (the BambuStudio `use_double_extruder_default_texture: true` flag covers the dual TOOL HEADS, not dual filament extrusion — A2L must NOT route AMS to the deputy slot or firmware rejects with 07FF_8012). Specs page also confirms NO Ethernet (Wi-Fi 2.4 GHz 802.11 b/g/n only), `Low-Rate-Kamera` on the chamber-image protocol (port 6000, NOT RTSP:322), no heated chamber. **Registry updates**: `PRINTER_MODEL_MAP` + `PRINTER_MODEL_ID_MAP` + `LINEAR_RAIL_MODELS` in `utils/printer_models.py`; `MODEL_TO_API_KEY` + `API_KEY_TO_DEV_MODEL` + `API_KEY_TO_WIKI_PATH` in `firmware_check.py` (wiki path follows the established `/en/a2l/manual/a2l-firmware-release-history` pattern; the existing 404 handling in `_fetch_all_versions_from_wiki` makes this safe to ship before Bambu publishes the page); `VIRTUAL_PRINTER_MODELS` + `MODEL_SERIAL_PREFIXES` in `virtual_printer/manager.py` (prefix `26A19A` with the same revision-letter padding as X2D's `20P90A`); `MODEL_PRODUCT_NAMES` in `virtual_printer/mqtt_server.py`; `mapModelCode` + Add-Printer / Edit-Printer model dropdowns in `PrintersPage.tsx` (new "A2 Series" optgroup); `mapModelCode` in `SpoolBuddyAmsPage.tsx`. **Camera and dual-nozzle code paths need no edits**: `supports_rtsp()` correctly falls through to chamber-image for A2L because `N9` is neither in the internal-code RTSP set nor does the display name match the X1/X2/H2/P2 prefix tuple; `is_dual_nozzle_model()` correctly returns False because A2L is not in `DUAL_NOZZLE_MODELS`. The cutter/plotter capability surfaces in MQTT push fields Bambuddy doesn't yet model; ignored for v1, will surface as a follow-up only if a real-world A2L bundle reveals a confusing UI state. **Tests**: 12 new cases in `test_printer_models.py::TestA2LModel` pinning every dimension — rod type, model-id round-trip, both ethernet directions, both camera-port directions, the explicit non-dual-nozzle guard (regression guard for the BambuStudio profile flag misread), set membership in `LINEAR_RAIL_MODELS` and exclusion from `CARBON_ROD_MODELS` / `STEEL_ROD_MODELS`. - **One-shot `device.*` identification probe in MQTT push parser (#1684 enabler)** — Adding support for a new Bambu printer model needs the internal model code the firmware sends in MQTT `device.dev_model_name` (e.g. A1 is `N2S`, H2C is `O1C`, X2D is `N6`). The field arrives on every push but Bambuddy never logged it, so even a debug-enabled support bundle from a new-model user (A2L on #1684 was the case that surfaced this) gave us no way to identify the model — `get_version` was also missing because the printer disconnected right after the request topic subscription, which is a separate firmware quirk. **Fix:** at the top of the existing `device.*` parsing block in `bambu_mqtt.py`, emit one INFO log per client session dumping `dev_model_name` / `dev_product_name` / `dev_id` / `project_name` if any are present; otherwise fall back to `device.keys()` so a future Bambu rename (e.g. `model_name` without the `dev_` prefix) still surfaces. INFO level so the line lands in every support bundle, not just debug-enabled ones; one-shot via a `_device_id_logged` flag matching the existing `_nozzle_fields_logged` pattern at line 2095 — no spam at every push_status. 3 unit tests in `TestDeviceIdentificationProbe` pin the one-shot behaviour, the known-id-field path, and the keys-fallback path. Full `test_bambu_mqtt.py` suite 281 / 281 green; ruff clean. Once this ships, a new-model issue self-resolves from the first bundle — no second round of "please enable debug and reupload" required. - **Re-print / Schedule modal: cross-extruder AMS slot picks on dual-nozzle (#1722, reported by @privatsturm)** — On a dual-nozzle setup (e.g. H2D with AMS A+C wired to the left extruder and AMS B wired to the right), the per-filament slot dropdown in the Re-print and Schedule modals used to hide every slot whose extruder didn't match the filament's slicer-assigned nozzle. A filament the slicer had assigned to the left extruder would only let the user pick from A or C; a right-assigned filament could only pick from B. Users who'd intentionally loaded the required filament into the "other" AMS — for example, AMS B (right side) carrying a colour the slicer had planned to print on the left — couldn't select it, even though the printer can physically run that AMS through its wired extruder. Three slice-output diffs (BambuStudio Desktop, OrcaSlicer Desktop, Bambuddy sidecar) all produced identical filament_map values for the same source 3MF, so the slicer wasn't the source of the asymmetry — Bambuddy's UI filter was. **Behaviour:** every loaded slot is now offered for every filament row in the Re-print and Schedule modals' specific-printer flow, regardless of which extruder it's wired to. The L/R badge on the filament row stays as a visual hint to what the slicer planned; the dropdown now trusts the user to pick based on their physical setup. Single-nozzle printers and FTS-equipped setups are unchanged — both short-circuited the filter already and continue to. Printer firmware accepts or rejects the resulting `ams_mapping` at start-print, so a physically-impossible pick fails loudly rather than silently. **Implementation:** `FilamentMapping.tsx:248-254` carried a guard `f.extruderId === item.nozzle_id` on the slot dropdown's `loadedFilaments` filter; the guard is now removed. The single-nozzle and FTS short-circuits stay. **Tests:** `'still applies the per-nozzle filter when FTS is null'` flipped to `'offers cross-extruder slots in the dropdown without FTS (#1722)'` — same scenario (no FTS, AMS 0 on right, filament asking for left), but now asserts both slots ARE listed. The FTS-installed case (#1162) and the rest of the FilamentMapping suite stay green. Backend untouched; no schema, no i18n. 5/5 FilamentMapping vitests green; 1043/1043 full component sweep green; frontend build clean. - **Support bundle now includes redacted cached push_status per connected printer** — The existing support bundle (`GET /support/bundle`) shipped `support-info.json` + `bambuddy.log` — useful for triage, but missing the one thing that consistently blocks per-model work: the raw shape of the printer's MQTT push_status payload. Bambu firmware ships per-model config in a different shape for every family — AMS Backup detection was deferred in `85fbd7fc` because the H2D's bit-26 of `print.cfg` doesn't translate to the X1C / P1S / P2S layout and we had no ground-truth samples to map them; the same gap surfaces every time a `vt_tray` / `vir_slot` / `mapping` shape varies across firmware (the P2S `tray_now` fix, the H2D `vir_slot` parsing, the round-5 `vt_tray` overlay fix from #1622 last week all needed wire samples to land). **What's new:** the bundle now contains a `push-status/printer-{i}.json` file per connected printer, indexed against `support-info.json["printers"]`. Each file carries `{model, firmware_version, captured_at, raw_data}` where `raw_data` is the live cached push_status from `BambuMQTTClient.state.raw_data`. Disconnected printers (no MQTT state, or `raw_data` empty) are skipped — there's nothing to capture and an empty file just adds noise. **Redaction (two-pass):** a structural pass via the new `_redact_raw_push_status` helper drops user-private top-level keys anywhere in the tree (`subtask_name`, `gcode_file`, `gcode_file_prepare_percent`, `subtask_id`, `task_id`, `project_id`, `design_id`, `profile_id`, `model_id`, `gcode_state`) — Bambu's per-print filename/cloud-ID surface — and rewrites every `net.info[*].ip` entry to `"0.0.0.0"`, mirroring the LAN-topology leak fixed for the virtual-printer bridge in #1429. **What's deliberately preserved:** `print.cfg`, `print.option`, `ams.*`, `vt_tray`, `vir_slot`, `mapping`, `ams_extruder_map`, hardware fields (`nozzle_diameter`, temperatures, layer counters). These are the fields per-model work depends on. The structural pass then runs through `sanitize_log_content` with the same DB-derived `sensitive_strings` map the log path uses (printer names, serials, IPs, access codes, usernames, Bambu Cloud email) — belt-and-suspenders against any user-named string that leaked into a tray UUID or a sub-brand field. The redactor returns a NEW dict and never mutates the live `state.raw_data` (the dispatcher reads it on every tick; mutation would race the next push). **Why always-on instead of opt-in:** the bundle endpoint is already gated on "debug logging must be enabled" — generating the bundle is an explicit user act, the file downloads to the user's machine before they choose to send it, and forcing a second toggle adds friction without changing the threat model. Once a handful of bundles arrive from new-model users we'll have what we need to unblock AMS Backup awareness in the print-queue deficit check, plus future per-model shape variance. **Tests:** 5 new unit cases in `test_support_helpers.py::TestRedactRawPushStatus` pin the contract — drops the 9 user-private keys, rewrites `net.info[*].ip` while preserving `mask` siblings + sibling `net` keys, preserves `print.cfg` / `ams` / `vt_tray` / `vir_slot` / `mapping` / `ams_extruder_map`, does not mutate input, handles non-dict input gracefully (returns `{}` for None / list / str). Full support test surface 79/79 green (`test_support_helpers.py` + `test_support_api.py`); full backend suite 5937/5937 green with `-n 30`; ruff clean across the backend; frontend untouched but rebuild + i18n parity confirmed clean per `feedback_run_all_ci_checks`. No migration, no new i18n keys, no schema changes, no frontend changes. - **Windows installer build pipeline scaffolded** — Lays down the infrastructure for producing a self-contained Bambuddy Windows installer `.exe` that doesn't require Python, Node, or any other runtime on the target machine. The installer ships an embedded Python 3.13 distribution (matching the Dockerfile's `python:3.13-slim-trixie`), the pre-built React bundle, NSSM (service supervisor), and ffmpeg — everything Bambuddy needs to run end-to-end on a stock Windows 10/11 box. **Architecture:** install target `C:\Program Files\Bambuddy\`, data target `C:\ProgramData\Bambuddy\data\` (preserved on uninstall so reinstalls keep the database + archives), service registered via NSSM running as `LocalSystem` with autostart on boot (LocalSystem is required because the Virtual Printer feature needs to bind 322 / 990 / 8883, all privileged ports on Windows). Browser is the UI — Start Menu shortcut opens `http://localhost:8000`, no Tauri / Electron launcher in v1, which matches how every other Bambuddy platform already works. **Why this shape over a PowerShell `install.ps1`:** the script approach was tried first and abandoned. Each failure across the Windows host fleet is environmental drift (Python version mismatches, execution-policy variants, antivirus heuristics, missing MSVC runtimes, OneDrive-redirected `%APPDATA%`, ARM64 vs x64, PowerShell 5.1 vs 7.x semantics) — a script can't insulate against host state, and every fix you add for one machine breaks two others. The self-contained-bundle approach takes that whole class of failure off the table. **Files:** `installers/windows/build.py` stages everything under `installers/windows/build/staging/`, `installers/windows/bambuddy.iss` is the Inno Setup 6 script, `installers/windows/service/install-service.bat` + `uninstall-service.bat` wrap NSSM. `build.py` hard-fails on non-Windows hosts; cross-build under Wine is an unsupported escape hatch behind `--allow-non-windows`. **CI:** `.github/workflows/windows-installer.yml` runs on tag push (`v*`) and manual dispatch, uses `windows-latest`, downloads Inno Setup via Chocolatey, runs `build.py` + ISCC, uploads the `.exe` as both a workflow artifact and a release asset. **Scope clarification:** this commit lands the build infrastructure, not a verified-working installer. The first real Windows-box smoke test happens after merge by triggering the workflow manually and installing the artifact on a target box; known unknowns are pip-installing `opencv-python-headless` / `curl_cffi` / `asyncpg` / `cryptography` / `bcrypt` against embedded Python (the `_pth` file edits in `build.py` cover the common gotchas but real-runtime imports are where surprises surface), ffmpeg path lookup from a LocalSystem service, and NSSM `AppEnvironmentExtra` line-continuation in cmd.exe. **Signing:** v1 ships unsigned — Windows SmartScreen will warn "Windows protected your PC" on first run, click-through works. SignPath OSS application submitted 2026-06-10 to wire free EV signing into CI once approved (typical 1–3 week approval window). **What's explicitly NOT in v1:** Spoolman bundling (Bambuddy's internal-inventory mode is the v1 default on Windows; users who want Spoolman install it separately), in-place upgrade (uninstall + install cycle works, but in-place upgrade-on-top needs end-to-end verification before we promise it), port-conflict pre-check (deferred to v1.1 — port collisions surface at first service start and the user reads the NSSM stderr log under `C:\ProgramData\Bambuddy\logs\service-stderr.log`). See `installers/windows/README.md` for the full build pipeline. - **VP wire-payload dump escape hatch for shape-of-payload triage (#1622 investigation)** — When a virtual printer in non-proxy mode is misbehaving for the slicer-facing surface (AMS slot fields rendering empty, filament dropdown unselectable, K-profile not visible), the existing logs prove the bridge is bound and pushing at 1Hz but don't show what's actually in the wire payload. Without that, "cache is missing fields" is indistinguishable from "the slicer-facing copy is stripping them." Set `BAMBUDDY_VP_DUMP_WIRE=1` and Bambuddy writes the bridge's cached push_status (`/vp_wire/_in.json`) and the periodic 1Hz copy that gets sent to the slicer (`/vp_wire/_out.json`) to disk, overwritten on each tick. Diffing the two answers the bisect question; diffing a misbehaving VP's `_out.json` against a known-good VP's `_out.json` (e.g. P1S vs H2D in the #1622 case) answers the model-shape question. Off by default, no overhead when disabled (single env-var read per tick); env var re-read on every call so toggling without restart works; failures swallowed at debug so a broken dump can never break the 1Hz loop. Implementation lives in `backend/app/services/virtual_printer/_debug.py` with call sites in `mqtt_server.py::_send_status_report` (cached branch only — synthetic fallback is uninteresting for this triage) and `mqtt_bridge.py::_on_printer_raw` (immediately after the merge that produces `_latest_print_state`). 21 unit tests in `test_vp_wire_dump.py` pin: disabled-by-default, atomic tmp+rename writes (no half-written .json visible to a reader), sanitized vp_name (path-separator stripped, empty name falls back to `vp`, .. inside a single filename component is harmless because slashes are collapsed before path construction), per-call env check, dict + bytes + str payload acceptance, swallow-on-OSError. Not gated on debug-logging because the bridge's verbose path is already noisy; this dump is small (one file per direction per VP) and only present when the operator opts in. Diagnostic-only — does not change the bridge data path. - **VP slicer↔printer command-flow trace (#1622 round 2)** — The snapshot dump above answers "is the cached push shape correct?", but the round-1 captures from #1622 ruled that out: P1S AMS payload reaches the slicer byte-identical to what the printer sent, sticky-key preservation works, the visible slot data is intact. The remaining symptom (picking a generic filament in archive mode "unloads" the slot) lives on the command path, not in the periodic push — and the snapshot dump doesn't capture command traffic. Same env flag (`BAMBUDDY_VP_DUMP_WIRE=1`) now also appends every slicer-originated publish on `device//request` AND every printer-originated response the bridge fans out to the slicer (extrusion_cali_get, ams_filament_setting acks, xcam, system, etc.) to `/vp_wire/_cmd.jsonl`, one JSON line per event with UTC iso timestamp, direction (`slicer_to_bridge` / `printer_to_slicer`), MQTT topic, a `.` grep handle, and the parsed payload. Excludes the cached-as-base 1Hz push (already covered by the snapshot dump) and `pushall`/`get_version` (handled locally, never forwarded). Printer-side captures happen AFTER serial rewrite so the dump matches what the slicer actually saw on the wire. New `append_event` helper in `_debug.py` mirrors the same swallow-on-OSError + sanitized-vp_name + per-call env-check posture as `dump_wire`; bytes payloads are utf-8 decoded then json-parsed with the same `\x00`-tolerance fix from #927 so OrcaSlicer's C-string-null publishes parse cleanly; un-parseable bytes fall back to `{"raw": "..."}` so every line stays valid JSON. Eight additional unit tests in `test_vp_wire_dump.py` pin: disabled-by-default, bytes parsing, trailing-null tolerance, unparseable-fallback, vp_name sanitization, iso timestamp shape, append-multiple-lines, swallow-on-OSError. Diagnostic-only — does not change the publish or fan-out data path. - **VP bridge-synthesised reply trace (#1622 round 3)** — The round-2 cmd.jsonl from shaddowlink's P1S vs H2D capture proves the actual failure mode: on P1S in archive mode the slicer issues `extrusion_cali_set` (push K/n directly) and the printer responds `fail`, on H2D and on the P1S second round the slicer takes the `extrusion_cali_sel` flow (select by `filament_id` / `cali_idx`) and the printer responds `success`. Both flows traverse the bridge cleanly — `ams_filament_setting` round-trips with `result=success` and the cached push_status carries `tray_info_idx=GFA11`, `tray_type=PLA-AERO`, K/n, and `cali_idx=-1` intact. So the bridge is innocent on every layer the dump can see, and the open question becomes: what makes the slicer pick `_set` vs `_sel`? Likely candidates are the `info.get_version` answer Bambuddy synthesises (slicer fingerprints on `sw_ver` / `hw_ver` / `module` to decide its command flow) or the first cached `pushall` response the slicer reads to bootstrap its UI. Round 2 captured neither — the JSONL had `slicer_to_bridge` and `printer_to_slicer` directions but no `bridge_to_slicer` direction for the bridge's own synthesised replies. Same env flag (`BAMBUDDY_VP_DUMP_WIRE=1`) now also appends every bridge-synthesised reply (info.get_version answer, project_file ack, on-demand pushall response) to `/vp_wire/_cmd.jsonl` under direction `bridge_to_slicer`. Capture lives in `mqtt_server.py::_publish_to_report` — the single chokepoint every synthesised reply already passes through — gated on a new `log_event: bool = True` parameter; the 1Hz periodic-push path threads `log_event=False` so the JSONL isn't flooded with ~60 lines/min per VP (snapshot dump already covers cache shape). The on-demand pushall response from `_send_status_report` IS logged because that's the bootstrap-fingerprint reply the slicer reads on first connect. Two additional unit tests in `test_vp_mqtt_bridge.py::TestWireFormat` pin the event-on-default and skip-when-`log_event=False` posture; `test_vp_wire_dump.py` already covers the underlying `append_event` shape and the new direction is documented in `_debug.py`'s docstring. Diagnostic-only — does not change the publish data path; the new param defaults preserve every existing call site's behaviour. - **Batch grouping for queued items** — multi-plate prints from one source 3MF now auto-group into a single collapsible row with aggregate stats, and a new "Group as batch…" action turns any 2+ selected items into a manual batch. Per-batch collapse state persists across reloads. Manual batches can be disbanded via the Ungroup action on the batch parent. - **History batch grouping** — siblings of the same batch collapse into one history row with status-rollup chips (e.g. 3 ✓ / 1 ✗) and the latest activity timestamp. - **History thumbnail hover preview** — hover any small history thumbnail and a 192×192 preview pops out next to it. ### Changed - **Queue page restructured around three tabs** — Queue, History, and Timeline now live as separate tabs at the top. History no longer competes with the active queue for screen space. - **Active queue layout toggle** — pick between a flat list (current default) and a per-printer view where each printer becomes a section card with aggregate item count, total time, and total filament weight in its header. - **Multi-drag reorder** — selecting N items and dragging any one of them moves the whole selection as a contiguous block; the drag ghost shows a "+N" badge. - **History rows redesigned** — each row now carries a filament color swatch + weight + type, the user who started the print, and the failure reason inline on failed/skipped rows. Rows lay out in a responsive 1/2/3 column grid so a long history uses available horizontal space. - **Timeline tab rebuilt as a Gantt swimlane** — one horizontal row per printer (plus per target_model and unassigned), jobs rendered as bars positioned by start time and sized by duration. Live NOW marker, 24-hour rolling window with 12-hour step controls. Only committed schedules are shown — staged items, waiting items, and ASAP jobs on idle printers are hidden so the timeline reads as a real forecast. ### Fixed - **Virtual Printer queue mode: multi-plate "Send All" now enqueues one queue item per plate** — BambuStudio / OrcaSlicer's "Send All" packs every plate of the project into a SINGLE 3MF and uploads it with one FTP STOR — `slice_info.config` inside the file carries N `` blocks (one per plate), each with its own `` and its own `Metadata/plate_N.gcode` payload. Previously the VP queue path only ever extracted the FIRST plate's index via `_extract_plate_id` and created exactly ONE PrintQueueItem with that single `plate_id`; plates 2..N silently dropped on the floor. Indistinguishable from the user's perspective from "Send" of a single plate — except they expected 3 items in the queue and got 1, with no log line to explain why. **Confirmed against the wire** on the live H2D-1 Proxy VP: `Cube.gcode.3mf` carrying three `` blocks (indices 1, 2, 3) + three per-plate gcode payloads in the same zip, identical filename whether "Send" or "Send All" was clicked — the only signal of intent is the count of `` blocks inside the file. **Fix:** replaced `_extract_plate_id` (returning `int | None`) with `_extract_plate_ids` (returning `list[int]`). The list contains every `` block's `index` metadata, in order; falls back to `[1]` for files missing `slice_info.config` or with no parseable plates so the single-plate path is preserved. `_add_to_print_queue` now loops over the list — each iteration calls `extract_filament_requirements(file_path, plate_id)` per-plate (the plate-aware path was already there from the #1697 work) and creates a PrintQueueItem with that plate's filament types / overrides, plate-specific position = `MAX(position) + iteration`. Single-plate "Send" hits the loop once → exactly today's behaviour (one queue item, plate_id from the slicer, same archive). Multi-plate "Send All" of a 3-plate file → 3 queue items, plate_id 1/2/3, consecutive positions, all pointing at the same backing archive (one upload = one archive). **What stays the same:** the single archive row per upload (the archive backs the queue items via `archive_id`); the `auto_dispatch=False` / `manual_start=true` posture inherited from the VP config (so multi-plate items still require manual start); the `queue_force_color_match` per-VP toggle (now applies per-plate). **What this also fixed downstream:** the `required_filament_types` / `filament_overrides` JSON on each queue item now reflects THAT plate's filaments, not the file's first plate — so the scheduler's per-printer "Any X" matching dispatches each plate onto a printer with the right colours loaded for THAT plate, not for plate 1's filament set. **Tests:** 1 new regression case in `test_virtual_printer.py::TestVirtualPrinterInstance::test_add_to_print_queue_multi_plate_send_all_enqueues_one_per_plate` — builds a 3-plate 3MF (writes the per-plate `` blocks into `slice_info.config` and the per-plate gcode payloads), runs `_add_to_print_queue`, asserts 3 PrintQueueItems with `plate_id == [1, 2, 3]`, `position == [1, 2, 3]`, shared `archive_id`, all `manual_start=True`. 126 existing single-plate VP tests stay green (loop runs once when input has one plate). Full backend suite 5962/5962 green; ruff clean; frontend untouched. **Live-verified** on the H2D-1 Proxy VP — a Send All of the 3-plate Cube project now produces 3 queue items + 1 archive instead of 1 queue item + 1 archive. - **Archive delete now removes related queue items instead of leaving "cancelled" rows behind** — Previously the soft-delete path (the default — what the trash-can button does) called `_cancel_pending_queue_items`, which only flipped queue rows with `status='pending'` to `status='cancelled'` while leaving every other status alone AND leaving every row in the DB. The Send All multi-plate work above made this much more visible: deleting an archive backed by N queue items now had to clean up N rows, and what users saw instead was N "cancelled" rows lingering in the queue history. **Fix (backend):** replaced `_cancel_pending_queue_items` with `_delete_related_queue_items(db, archive_id) -> int` that DELETEs every queue row where `archive_id = X` regardless of status. Behavior now matches what the hard-delete path already did via the `ON DELETE CASCADE` FK on `print_queue.archive_id` — both paths produce the same end state. Print history lives in `PrintLogEntry` (FK `ON DELETE SET NULL`) and is untouched, so stats / Quick Stats / accuracy bands are preserved across both delete paths. **New guard:** the route at `archives.py::delete_archive` now 409s when any related queue item is currently in `status='printing'` — both soft and hard delete are gated by the same precondition, because deleting the archive while a print is live would strip the dispatcher's metadata trail (filament / plate / ams_mapping) out from under the running print. The 409 surfaces a clear "Stop the print first, then retry" message. **Pre-flight count for the UI:** new endpoint `GET /archives/{id}/delete-impact` returns `{related_queue_items: N, currently_printing: M}` — cheap, single endpoint, not folded into the archive list response so the much larger list endpoint isn't forced to run the same query per row. Frontend ArchivesPage delete-confirm modal queries this when the modal opens (`useQuery({queryKey: ['archive', id, 'delete-impact'], enabled: showDeleteConfirm})`) and renders: an amber warning "**N queue item(s) linked to this archive will also be removed.**" when total > 0 AND printing = 0, OR a red warning "**Cannot delete — M queue item(s) are currently printing. Stop the print first, then retry.**" when printing > 0 (with the confirm button disabled in that case so the user can't bonk the 409 on submit). **ConfirmModal extension:** added optional `confirmDisabled?: boolean` prop. Existing `isLoading` was the only disable knob; this adds an external-precondition path that disables the confirm without the loading spinner. **Tests:** rewrote `test_print_queue_api.py::test_soft_delete_archive_cancels_pending_queue_items` → `test_soft_delete_archive_deletes_all_related_queue_items` to pin the new contract (both pending AND completed rows are gone post-soft-delete). 2 new integration cases in `test_archives_api.py`: `test_delete_archive_blocked_when_related_queue_item_printing` (both soft and hard paths return 409 with "printing" in detail message) + `test_archive_delete_impact_reports_counts` (3 mixed-status related rows + 1 unrelated row → endpoint reports `related_queue_items=3, currently_printing=1`, unrelated row doesn't bleed in). **i18n:** 2 new keys (`archives.modal.deleteQueueItemsWarning`, `archives.modal.deleteBlockedByPrinting`) translated across all 11 locales per `feedback_translate_dont_fallback` — no English fallbacks. **Verification:** full backend suite 5964/5964 green with `-n 30`; ruff clean; ESLint clean; `npm run build` clean; vitest 2118/2118 green; i18n parity 5109 × 11 locales green. No DB migration — the CASCADE FK was already in place; only the helper's semantics changed. - **Print Log table: multi-color filament rows render one swatch per color instead of a single barely-visible gray dot (#1731 part 1, reported by @IndividualGhost1905)** — The per-archive Print Log table cell at `frontend/src/pages/ArchivesPage.tsx:3882` rendered the `filament_color` column as ONE swatch with `style={{ backgroundColor: entry.filament_color.startsWith('#') ? entry.filament_color : undefined }}`. For multi-color prints, the backend writes `filament_color` as a comma-joined string (e.g. `"#FFFFFF,#000000,#FF0000"` — three filaments used in the print), which trivially passes the `.startsWith('#')` check but is not a valid CSS color. The browser silently dropped the `backgroundColor` declaration, leaving the swatch as only its black/20% border on the app's dark theme — visually a tiny grey dot, near-invisible against the row background, which the reporter's screenshots showed as "PLA" text in the cell with no apparent swatch at all. The DB column was correct (the reporter confirmed both colors were recorded for the old example); the render dropped them. The Archive Card view at `:1072-1083` and `:2114-2125` already split on comma and rendered one swatch per color — only the Print Log table cell had been missed when multi-color support was added across the rest of the page. **Fix:** the Print Log table cell now mirrors the card-view pattern — wraps the swatches in a `flex` container, splits `entry.filament_color` on `,`, trims each value, and renders one `w-3 h-3 rounded-full` per color with `backgroundColor: trimmed.startsWith('#') ? trimmed : undefined` and a `title={trimmed}` for hover-tooltip parity. Single-color prints render exactly one swatch (the trivial case — no behaviour change). Empty / non-hex slot values gracefully fall through to no `backgroundColor` rather than poisoning the CSS for adjacent slots. The filament-type text (`{entry.filament_type || '—'}`) keeps its existing position to the right of the swatches. **What this does NOT fix:** the reporter also flagged that new multi-color prints don't appear in the filament usage history. That's a separate code path (`backend/app/services/usage_tracker.py::_track_from_3mf` and the slot-to-tray mapping chain at `usage_tracker.py:899-901`), where the diagnostic needs the archive's captured `ams_mapping`, the `mapping` field from MQTT push_status at print start, and the `[UsageTracker] PRINT START` / `PRINT COMPLETE` log lines — none of which are in the reporter's first bundle. Tracking under #1731 part 2, blocked on a support bundle from the affected install. **Tests:** existing `ArchivesPage.test.tsx` (23 cases) green; ESLint clean; `npm run build` clean; i18n parity 5107 leaves × 11 locales green (no new keys). Frontend-only change. - **Finish-photo force-on removed; user's explicit timelapse=off in the slicer send dialog is now respected (#1721, reported by @agrisci)** — On H2D 01.x firmware, `capture_finish_photo` (default-on global setting) was forcing every print's `timelapse` MQTT field to `enable` regardless of whether the user had unchecked the Timelapse box in OrcaSlicer's send dialog. That bit flips the printer's runtime `timelapse_record_flag`, which un-gates the slicer-baked `M1002 judge_flag timelapse_record_flag` / `M622 J1` / `G1 X-48.2 F3000` / `M971 S11 C11 O0` wipe blocks emitted by **Smooth**-mode timelapse profiles — so the toolhead parked off the part and snapped a frame every single layer, on prints the user explicitly opted out of recording. The reporter's gcode export confirmed the macro block was baked in (28 occurrences across the file) and the printer's MQTT log showed `Sending print command: {"print": { … "timelapse": true, … }}` even though the slicer-side checkbox was unchecked. Live-stop confirmed: turning the global `capture_finish_photo` setting off in Bambuddy made the per-layer parking stop immediately. **Root cause:** the #1397 "finish photo from timelapse" feature used "force the printer into timelapse-recording mode at dispatch" as the side-channel to get a well-framed end-of-print shot (toolhead parked, before bed drop, extracted from the recorded video's last frame). That mechanism conflated two semantically different things — recording a timelapse video vs. snapping a finish photo — and the per-layer side effects of the recording mode were decided at slice time by the user's `timelapse_type` profile setting, which Bambuddy has no visibility into post-slice. Traditional-mode gcode has no per-layer wipe block (no parking, no defects) — so the bug was invisible to anyone whose slicer profile defaults to Traditional. Smooth-mode gcode (the reporter's case) bakes the wipe block and gates it on `timelapse_record_flag`, so flipping the runtime flag fired the macro every layer. **Fix:** replaced the force-on mechanism entirely with a clean MQTT-state-driven trigger. `bambu_mqtt.py::_handle_push_status` now fires a new `on_finish_photo_moment` callback when `stg_cur` transitions INTO **22** ("Filament unloading") while `_was_running == True` AND the end-of-print gate matches (`progress >= 99` OR `layer_num >= total_layers` OR `remaining_time <= 0`) — that's the same framing window #1397 was after (toolhead parked, bed not yet dropped, AMS pulling filament back) but reached via a clean state signal instead of by exploiting the per-layer macros. The end-of-print gate is what disambiguates from mid-print filament swaps in multi-color prints, which ALSO transit through stage 22 (M620 unload → 22, M621 load → 24) but always at progress < 99 / layer < total / remaining > 0. A FINISH-state fallback in the same handler fires the same callback at the existing FINISH-state transition if stage 22 never arrived — covers cancel-mid-print (state goes RUNNING → IDLE / FAILED without 22), external-spool-only prints where some firmwares skip the unload phase, HMS halts before unload, and any firmware variant we don't see stage 22 on. Net behavior: every print that gets a finish photo today still gets one; the lucky majority get the better-framed pre-bed-drop shot too. `main.py::on_finish_photo_moment` is a new top-level handler that pre-captures one camera frame at the trigger edge — external camera (snapshot URL → MJPEG fallback), buffered live RTSP frame from `_active_streams` / `_active_chamber_streams`, or a fresh RTSP grab via `capture_camera_frame_bytes` — and caches the JPEG bytes in a module-level `_stage22_finish_frames: dict[int, bytes]` keyed by printer_id. `_background_finish_photo` (inside `on_print_complete`) consumes the cached bytes via `_stage22_finish_frames.pop(printer_id, None)` before falling through to its existing live-grab chain, so the saved photo has the better framing without the existing complex archive-resolution / fallback / notification wiring needing to move. When a timelapse IS actively recording (user explicitly opted in this time), the pre-capture is skipped — `_capture_finish_photo_from_timelapse` still extracts the last frame from the recorded video, which is still the highest-quality option and now has no force-on side effects because the user actually wanted the video. **What was removed:** `resolve_effective_timelapse` in `background_dispatch.py` (the shared force-on resolver), `BackgroundDispatchService._resolve_effective_timelapse` wrapper, both call sites in `background_dispatch.py` (`_run_reprint_archive` + library-file print path), the `resolve_effective_timelapse` call in `print_scheduler.py::_dispatch_item`, the `archive.bambuddy_forced_timelapse` write in the resolver, the `if archive.bambuddy_forced_timelapse: await _cleanup_forced_timelapse(...)` branch in `_background_finish_photo`, and the entire `_cleanup_forced_timelapse` function (~75 lines including the FTP-DELE walk across `/timelapse` / `/timelapse/video` / `/record` / `/recording`). All call sites now read `bool(item.timelapse)` / `bool(job.options.get("timelapse", False))` directly — the literal user choice flows straight through to `start_print(timelapse=…)`. The `archive.bambuddy_forced_timelapse` DB column stays defined (default `False`) for back-compat with existing rows that may have it set to `True` from before — no consumer reads it anymore, and dropping a column on the user-data table risks breaking restore-from-backup flows we don't need to break. **New callback wiring:** added `on_finish_photo_moment` parameter to `BambuMQTT.__init__`, new `_finish_photo_captured` one-shot flag (reset on each new print at the same site as `_completion_triggered`), new `PrinterManager._on_finish_photo_moment` field + `set_finish_photo_moment_callback` setter, new `on_finish_photo_moment` inner wrapper in `_setup_callbacks`, threaded through to the `BambuMQTTClient` constructor call. `main.py::on_print_start` clears any leftover `_stage22_finish_frames` entry from a prior print so a never-consumed cache (e.g. capture succeeded but on_print_complete bailed before reaching it) can't bleed into the new print's photo. **Tests removed:** `test_cleanup_forced_timelapse.py` (~290 lines, 7 test cases pinning the FTP-DELE walk and `bambuddy_forced_timelapse` flag handling), `test_scheduler_force_timelapse_wiring.py` (the source-pattern check that pinned `print_scheduler.py` imports `resolve_effective_timelapse`), `test_dispatch_force_timelapse.py` (5 test cases pinning the `_resolve_effective_timelapse` wrapper's interaction with `capture_finish_photo` + archive flag). The behaviour these tests verified is intentionally gone. **Tests updated:** `test_background_dispatch_watchdog.py` dropped two `patch.object(BackgroundDispatchService, "_resolve_effective_timelapse", ...)` blocks that stubbed the now-removed method; `test_background_dispatch.py::test_dispatch_options_pass_through_pattern` comment updated to explain why `timelapse` stays excluded from the bare-pattern needle check (the wrap in `bool(...)` is intentional to coerce non-bool option payloads, not a force-on remnant). **Verification:** ruff clean; full backend suite 5961/5961 green with `-n 30`; ESLint clean; `npm run build` clean; vitest 2118/2118 green; i18n parity 5107 leaves × 11 locales green (no new keys). No migration. **What this does NOT change:** users who explicitly enable the Timelapse checkbox in the slicer send dialog still get the timelapse video AND the timelapse-extracted finish photo (highest-quality framing, no per-layer parking because that was never the issue — it's the user's intentional choice). Users who explicitly disable the Timelapse checkbox now get no per-layer parking AND still get a finish photo (pre-captured at the stage-22 edge for the same pre-bed-drop framing). - **Configure AMS Slot: filament profiles for other printer models now filtered out (#1623, reported by @shaddowlink)** — Three independent gaps in the same picker, each surfaced by a different round of reporter screenshots. **(1) Local "Custom" imported profiles** were unconditionally listed regardless of the slot's printer; a user with PETG / PLA profiles imported from OrcaSlicer / BambuStudio for A1 mini, H2D, and P1S saw all three lined up when configuring an AMS slot on any one of those printers. **(2) Cloud presets using the `@Bambu Lab ` suffix form** (user-renamed Bambu Cloud presets and most Orca Cloud profiles) slipped through the existing filter, which only matched the `@BBL ` form Bambu's system presets use. **(3) Cloud presets with the printer model in the BODY of the name** (the literal failure shape the reporter screenshotted on H2D: `"X1C eSUN PETG-Basic Filament"` with no `@` suffix at all) — the existing extractor returned null for these and the filter no-op'd. **Fix:** `ConfigureAmsSlotModal.tsx` now (a) queries the backend's Bambu printer-model registry (`/slicer/printer-models`, same fetch SliceModal uses), (b) for local presets — reverse-looks-up the slot's short model code to a long printer-preset fragment, pairs it with the slot's nozzle diameter to synthesise the full slicer preset name ("Bambu Lab P1S 0.4 nozzle"), and passes that into `presetCompatibility(...)` from `utils/slicerPrinterMatch.ts` against each local preset's parsed `compatible_printers` JSON; (c) for cloud / Orca Cloud presets — `extractPresetModel(name, registry)` is now multi-strategy: first the `@BBL ` form (existing), then the `@Bambu Lab ` form with case-insensitive reverse-lookup against the registry (so "A1 mini" vs "A1 Mini" capitalisation drift doesn't hide A1 Mini profiles, preserving the #1649 alias-aware match), then a body-text scan against every known model token (long-name fragments and short codes from the registry, long-first sort so "A1 Mini" / "X1 Carbon" / "H2D Pro" aren't eaten by their shorter siblings, word-boundary regex so "PA1" doesn't match "A1" and "X1Box" doesn't match "X1"). Presets where no strategy resolves still pass through — free-form names with no recognisable model token stay visible (can't filter what we can't classify). **Fail-open posture preserved:** `match` and `unknown` verdicts keep showing for local presets (back-compat for hand-edited imports without `compatible_printers`); the currently-configured preset (`slotInfo.savedPresetId`) bypasses the filter so the active selection always remains visible; built-in filaments stay unfiltered (generic fallback); when the registry hasn't loaded yet OR `printerModel` is empty, every filter no-ops. **No backend / schema / i18n changes.** Frontend ESLint clean; `npm run build` clean; vitest `ConfigureAmsSlotModal` 24/24 green. - **Virtual Printer: empty AMS slots forwarded as phantom loaded filaments to BambuStudio Sync (#1726, reported with full code-level analysis by @needo37)** — On any VP bound to a target printer (Proxy mode, or Queue mode with a specific target), the slicer-facing AMS state was the printer's raw push_status — the empty-slot cleanup that `bambu_mqtt.py::_handle_ams_data` applies to Bambuddy's own internal state was NEVER run on the bridge cache. Concrete case: real printer has 3 filaments loaded (AMS-A slots 2/3/4), AMS-A slot 1 and all of AMS-B empty; Bambuddy's AMS card renders the empty slots correctly as Empty (control — internal state path is fine), but BambuStudio after Sync paints 7 populated/green-checked filament slots — the 3 real ones plus 4 phantoms whose color/material is stale RFID/calibration data from before those slots went empty. The diagnostic signature is the mismatch between the AMS card (correct) and the slicer view (wrong) for the same payload. Archive mode and Queue-by-model are NOT affected — no target printer → no bridge → the slicer gets the synthetic stub at `mqtt_server.py:927` with no real AMS data. **Root cause:** two code paths consume the same printer AMS payload. Internal (`bambu_mqtt.py::_handle_ams_data` lines 1802-1858) parses `tray_exist_bits`, promotes empty slots to `state=9`, and wipes the stale `tray_type`/`tray_color`/`tray_info_idx`/`tag_uid`/`tray_uuid`/`remain` fields. VP bridge (`mqtt_bridge.py::_on_printer_raw` lines 551-656) deep-merges AMS structurally via `_merge_ams_dict` and copies `tray_exist_bits` through as an opaque top-level scalar — but never applies the bit→clear-empty-slot logic. The cached state ships to the slicer untouched. **Fix:** factored the bit-clear logic out of `_handle_ams_data` into a shared module-level helper `bambu_mqtt.py::apply_tray_exist_bits(units, tray_exist_bits_str, *, power_on_flag, log_label)` and call it from both paths. The internal call site is replaced with a single helper invocation; the bridge calls it on the merged AMS dict after `_merge_ams_dict` runs, before the 1 Hz cached-as-base push picks the cache up. Shared shutdown guard preserved on both sides: all-zero bits + `power_on_flag=False` is the printer-off pattern (#765) and skips cleanup — a non-zero bits + power-off combo is valid idle-printer state (#1365 — X1C between prints) and still applies. AMS-HT units (`id >= 128`) skipped on both sides (separate addressing scheme). **Tests:** new `TestApplyTrayExistBitsHelper` class in `test_bambu_mqtt.py` (10 cases pinning the helper contract directly — missing/unparseable bits → no-op, shutdown guard, nonzero+power-off X1C case, int-9 state, AMS-HT skip, string id handling, multi-AMS global bit math, state-promote-even-without-stale-data). 3 new bridge regression tests in `test_vp_mqtt_bridge.py::TestPushStatusCache`: `test_tray_exist_bits_clears_empty_slots_in_slicer_cache` reproduces the #1726 wire shape (slot 0 carries stale `tray_type`/`tray_color`/`tray_info_idx`/`tag_uid`/`tray_uuid`/`remain` + `tray_exist_bits="e"` → slot 0 must clear, slots 1-3 preserved), `test_tray_exist_bits_shutdown_guard_preserves_cache` pins the printer-off path won't propagate phantom empties on every reconnect, `test_tray_exist_bits_skips_ams_ht_units` pins the HT addressing skip. Existing internal-state tests for the bit-clear logic (`test_tray_exist_bits_clears_empty_slots`, `test_tray_exist_bits_promotes_empty_slot_to_state_9`, `test_tray_exist_bits_does_not_change_state_on_loaded_slots`, …) continue to pass against the refactored internal path — same contract, same behavior, different implementation seam. One pre-existing bridge fixture (`test_partial_ams_unit_update_preserves_other_units`) had an inconsistent `tray_exist_bits="3"` for two AMS units (bit 0 set, bit 4 unset, but both unit 0 and unit 1 had slot 0 populated as loaded). The fix exposed the inconsistency — corrected to `"11"` (bits 0 + 4) to match what the real printer would send. Full backend suite 5955/5955 green; ruff clean; i18n parity 5107 leaves × 11 locales green (no new keys). Frontend untouched. **Verification on a live system (per @needo37's analysis):** set `BAMBUDDY_VP_DUMP_WIRE=1`, restart, Sync the slicer, inspect `/vp_wire/_out.json`. For any tray whose bit in `tray_exist_bits` is 0, `tray_type`/`tray_color` should now be empty. - **Windows: `/api/local-backup/status` 500 on `ZoneInfoNotFoundError: 'No time zone found with key UTC'` (from a user's log on the Windows installer)** — Reported via a Windows traceback against the new local-backup status endpoint. The stdlib `zoneinfo` module reads the system IANA tz database on Linux/macOS, but Windows has none — and the embedded Python in our Windows installer doesn't carry the `tzdata` PyPI package either, so even `ZoneInfo("UTC")` raises `ZoneInfoNotFoundError`. `_local_zone()` in `services/local_backup.py` only caught that exception for the `TZ`-env branch; the empty-`TZ` fallback and the unrecognised-`TZ` fallback both unconditionally called `ZoneInfo("UTC")` and re-raised, bubbling out of the FastAPI handler as a 500. **Fix (two parts):** (1) `_local_zone()` is now resilient — return type widened from `ZoneInfo` to `tzinfo`, the `UTC` fallback is wrapped in its own try, and the last-resort fallback returns the stdlib `datetime.timezone.utc` (which needs no IANA DB and satisfies every `astimezone` / `str()` call site downstream — `str(timezone.utc) == "UTC"` matches the previous response shape). Restores function on existing Windows installs without re-bundling. (2) `requirements.txt` now pins `tzdata>=2024.1; sys_platform == "win32"` so the next Windows installer build ships the IANA DB and any non-UTC `TZ` value (e.g. `Europe/Berlin`) resolves correctly — the stdlib fallback can only ever give UTC. Linux/macOS unaffected: the platform marker keeps them on the system tz DB they already have. **Tests:** new `test_zoneinfo_completely_unavailable_falls_back_to_stdlib_utc` in `test_local_backup.py` monkeypatches `ZoneInfo` to always raise `ZoneInfoNotFoundError` and pins that `_local_zone()` returns `datetime.timezone.utc` rather than propagating. 31/31 local_backup tests green; ruff clean. - **Print-modal "off" toggles for `flow_cali` and `nozzle_offset_cali` now actually suppress the calibration stage (live-tested on H2D 01.x)** — The Re-print / Schedule modal toggles for Flow Calibration and Nozzle Offset Calibration accepted the user's "off" choice and flowed it correctly through to the `project_file` MQTT publish — Bambuddy sent `extrude_cali_flag: 2` and `nozzle_offset_cali: 2` per our reading of "1 = run, 2 = skip" inherited from the #1478 / #1682 work. Live test on an H2D running firmware 01.x: with both toggles off in Bambuddy's modal, the printer's `stg` queue (the pre-print stage list firmware publishes via push_status) still included stage **8** ("Calibrating dynamic flow") and stage **39** ("Nozzle offset calibration") — and physically ran them at print start. The `2` value did NOT suppress the stage despite our earlier "skip and reuse stored PA" reading. **Root cause:** the encoding for the "off" wire value is `0`, not `2`. The `2` value appears to mean "skip the explicit calibration pass but still apply / verify the stored PA value via the calibration stage" — close to a no-op in terms of K-factor but the printer still queues the stage and runs the per-print physical sequence. `0` is what actually drops the stage from the `stg` queue. A real BambuStudio Send-dialog capture on the same firmware (proxy-mode VP echo) also showed `0` for both fields when calibrations are unchecked, contradicting the #1478 commit message which read `0` as "never sent by BambuStudio." **Fix:** `bambu_mqtt.py::start_print` — `extrude_cali_flag` is now `1 if flow_cali else 0` (was `2`), and `nozzle_offset_cali` is `1 if (nozzle_offset_cali and is_dual_nozzle) else 0` (was `2`). The dual-nozzle gate stays — single-nozzle prints continue to force-skip the nozzle-offset calibration their head doesn't support (#1682). `1` (run) is unchanged on both fields. **Verification:** live re-test on the same H2D with both toggles still off — `stg: [29, 13, 4, 14, 3]` (cooling, homing, filament change, nozzle cleaning, vibration comp). Stages 8 and 39 dropped out cleanly. **What's NOT fixed:** `vibration_cali` is a JSON `false` bool in both Bambuddy's and BambuStudio's wire format, and the H2D firmware queues stage **3** ("Vibration compensation") regardless of the bool value — this is firmware-side and not solvable at our dispatch layer with the current field. Captured as a follow-up to investigate whether a parallel `vibration_cali_flag` integer field exists. **Tests:** `test_bambu_mqtt.py` — `test_p2s_uses_boolean_format` flipped `extrude_cali_flag == 2` → `== 0`; `test_nozzle_offset_cali_default_is_skip`, `test_nozzle_offset_cali_ignored_on_single_nozzle`, `test_nozzle_offset_cali_false_on_dual_nozzle` flipped `== 2` → `== 0`; docstrings updated to reflect the #1721 finding. The `1 if user_wants` branch in both tests for the "on" case is unchanged. 281/281 bambu_mqtt tests green; full backend suite 5941/5941 green with `-n 30`; ruff clean; frontend untouched (rebuild + i18n parity confirmed clean per `feedback_run_all_ci_checks`). - **Support-bundle log noise: VP bridge nudge + SD-card cleanup (#1721 adjacent, observed on reporter's A1)** — Two warnings polluting every A1 support bundle on a healthy print. Neither was the cause of #1721's timelapse complaint — both are adjacent noise. **(1) `request_status_update: not connected`** — `mqtt_bridge.py::_resolve_client` calls `_request_version` + `request_status_update` immediately after attaching a raw-message handler so the bridge cache populates without waiting for the next periodic pushall. The bind frequently races the real printer's MQTT TLS handshake — a slicer-side reconnect re-resolves the client before the underlying session has reconnected, especially on A1 firmware which reconnects more aggressively than X1/H2/P. `request_status_update` logs `[serial] request_status_update: not connected` at WARNING on the not-connected return path. The nudge is a best-effort optimisation; the fall-through (next periodic pushall) populates the cache anyway, so the WARNING fires on routine, expected, recoverable state. **Fix:** gate both nudges on `current.state.connected` at the bind site. When the client comes up, the next `_resolve_client` tick re-enters this branch on identity change OR the periodic pushall in `bambu_mqtt.py` fills the cache — same end state, no benign WARNING. The WARNING in `bambu_mqtt.py:3224` is unchanged: it's still a real signal for the other callers (`/printers/{id}/refresh-status` user API, bug-reporter helper) where "you asked for a refresh on a dead client" is genuinely worth logging. New `test_post_bind_nudge_skipped_when_target_not_connected` in `test_vp_mqtt_bridge.py::TestBridgeLifecycle` pins the contract. **(2) `SD card cleanup failed after 3 attempts ... (file may linger on SD card)`** — The post-finish helper in `main.py` deletes the uploaded file from the printer's SD card to prevent the ghost-print-on-power-cycle behaviour (#374, #1542). It tries up to three candidate paths (`derive_remote_filename(archive.filename)`, then `{subtask_name}.3mf`, then `{subtask_name}.gcode`), each up to 3 times with 2 s backoff, then logs WARNING if all fail. `delete_file_async` returned `bool` — `True` for success, `False` for ANYTHING else (FTP 550 file-not-found, network error, auth fail, transient FTP error). The A1 firmware (and most other Bambu firmwares post-print) cleans the SD-card upload itself before our cleanup runs, every candidate FTP-DELE returns 550, all three retries × three candidates × 2 s sleeps fire, then WARNING. That WARNING shouldn't exist on a healthy print where the printer self-cleaned. The same shape exists in `_cleanup_forced_timelapse` (#1397) walking the four timelapse dirs. **Fix:** `bambu_ftp.py` now exports a `DeleteResult` enum (`DELETED` / `NOT_FOUND` / `FAILED`). `BambuFTPClient.delete_file` detects the 550 case via `isinstance(e, ftplib.error_perm) and str(e).startswith("550")` (same pattern already used in the download path for the symmetric `FileNotOnPrinterError` sentinel from #972). `delete_file_async` now returns `DeleteResult`. Both post-finish cleanup helpers (`main.py::on_print_finished` SD branch + `_cleanup_forced_timelapse`) only WARN when at least one candidate returned `FAILED`; an all-`NOT_FOUND` outcome logs DEBUG ("nothing to delete — printer likely self-cleaned"). The cleanup helper also no longer burns the 2 s × 3 retry budget on a `NOT_FOUND` result (550 will never recover by waiting); only `FAILED` triggers backoff. `DELETE /printers/{id}/files/...` returns 404 (not 500) on `NOT_FOUND`, more accurate for the user-facing UI. Three other production callers (`print_scheduler` pre-upload delete, two `background_dispatch` fire-and-forget cleanups) are unchanged at the call site — they discard the return value. **Tests:** `test_delete_file` and `test_delete_file_async` in `test_bambu_ftp.py` switched to the enum (3 cases each). 2 new regression tests in `test_cleanup_forced_timelapse.py`: `test_forced_no_warning_when_every_dir_returns_not_found` pins the #1721 path (every candidate dir → 550 → no WARNING, one DEBUG summary), `test_forced_warns_when_any_dir_returns_failed` pins the counterpart (any FAILED keeps the WARNING — that's the signal the maintainer wants). `caplog` asserts the log record's level + content directly. Full backend suite 5941/5941 green; ruff clean; frontend untouched (rebuild + i18n parity confirmed clean per `feedback_run_all_ci_checks`). No migration, no new i18n keys, no schema changes. - **Virtual printer external spool (`vt_tray`) went "invalid" right after a slicer filament pick (#1622 round 5, reported by @shaddowlink)** — On a P1S in non-proxy VP mode, the reporter picked a filament for the external spool slot in BambuStudio's Device tab and the slot immediately rendered as invalid (color only, no profile, no K-profile, no nozzle temps), but recovered after a virtual-printer reload. AMS slot picks worked correctly. Wire dumps (BAMBUDDY_VP_DUMP_WIRE=1) captured the asymmetry: the bridge's outgoing 1 Hz cached-as-base push delivered `vt_tray = {tray_info_idx, tray_color}` — 2 fields — where a real P1S sends ~20 (`tray_type`, `state`, `remain`, `k`, `n`, `cali_idx`, `nozzle_temp_min/max`, `tray_uuid`, `xcam_info`, ...). The same `_out.json` showed AMS slots with the full 24-field dict because `_merge_ams_dict` deep-merged them. **Root cause:** Bambu firmware sends a partial `vt_tray` incremental right after acknowledging an `ams_filament_setting` for `ams_id=255` (external spool) — carrying just the fields the slicer's pick changed. The round-4 per-field accumulate (#1622 / da799447) carried over prev keys NOT present in new, but `vt_tray` IS present in new, so the cached dict was REPLACED wholesale with the 2-field partial. The next 1 Hz cached-as-base push handed the slicer the stripped vt_tray; BambuStudio rendered the slot as invalid. Reloading the VP forced a reconnect → pushall → full vt_tray restored, and the cycle repeated on the next pick. **Fix:** `mqtt_bridge.py::_on_printer_raw` now applies the same per-field accumulate one level deeper: for every top-level key whose prev AND new are both dicts, overlay new onto prev rather than replace. `ams` is explicitly excluded (already deep-merged by `_merge_ams_dict`). The same overlay protects `device`, `online`, `upgrade_state`, `ipcam`, `upload`, `net` against future firmware partials with the same shape; the `net.info` IP rewrite path is unaffected because `_rewrite_net_info_ips` runs against `new_state["net"]` before caching and the rewritten list overrides the cached one on overlay (only `net.conf` and friends, when sent without `info`, draw from prev now). **Tests:** new `test_partial_vt_tray_update_overlays_onto_cached_full_dict` regression case in `test_vp_mqtt_bridge.py::TestPushStatusCache` constructs the exact P1S wire shape — pushall with the full ~20-field vt_tray, followed by the `{tray_info_idx, tray_color}` partial that shaddowlink's dump captured — and asserts `tray_type`, `state`, `remain`, `k`, `n`, `cali_idx`, `nozzle_temp_min/max`, `tray_uuid`, `id` all survive while the two incoming fields take their new values. All 53 bridge tests stay green; 287/287 across the broader VP test surface (mqtt_bridge / mqtt_server / vp_wire / virtual_printer); ruff clean. Bridge code path only; no migration, no new i18n keys, no frontend touch. - **Library G-code preview returned raw ZIP bytes as `text/plain` for sidecar-sliced rows (#1709, root cause + fix from @yanglei1980)** — `slice_and_persist` writes its output as a `.gcode.3mf` (a ZIP container with embedded G-code) but persisted the LibraryFile row with `file_type="gcode"`. The G-code preview endpoint at `library.py::get_gcode` short-circuits on `file_type == "gcode"` and streams the on-disk bytes with `media_type="text/plain"`, so every preview of a sidecar-sliced row handed the embedded viewer the raw ZIP body (`PK\x03\x04…`) instead of toolpath text — the viewer rendered nothing. External-folder scans (#1600) already typed `.gcode.3mf` rows correctly and hit the unzip branch, so the bug was specific to the sidecar slice path. Plain `.gcode` uploads were unaffected (their on-disk bytes really are text). **Fix:** (1) forward — `slice_and_persist` now persists `file_type="gcode.3mf"`, matching what `_classify_file_type` returns for the `.gcode.3mf` extension and what external-scan rows already use; (2) back-compat — `get_gcode` also routes to the unzip branch when the filename ends with `.gcode.3mf`, so rows already written under the bug self-heal on first preview without a DB migration. **UI gates:** three frontend call sites that gated badge colour or the preview-eye icon on `file_type == "gcode"` were extended to also accept `"gcode.3mf"` — `FileManagerPage.tsx` badge + viewer-affordance gate, `ProjectDetailPage.tsx` badge — so the new typing doesn't regress visuals. The print / queue / slice action buttons use filename-based helpers (`isSlicedFilename`, `isSliceableFilename`) that already accept `.gcode.3mf`, so they need no change. **Tests:** new `test_library_get_gcode_recovers_legacy_gcode_type_for_3mf` regression case in `test_library_api.py` constructs a row with `file_type="gcode"` + `.gcode.3mf` filename pointing at a real ZIP, asserts the response is `text/plain`, contains `G28`, and does NOT start with `PK` — pins the legacy-row recovery path. Existing `test_library_get_gcode_endpoint_accepts_compound_file_type` continues to cover the forward path. Full backend suite 5920/5920 green; ruff clean; frontend ESLint + `npm run build` clean; FileManagerPage / ProjectDetailPage / FileManagerExternalFolder vitests 69/69 green; i18n parity unchanged (no new keys). PR #1709 closed for CONTRIBUTING.md non-compliance (branched from main, no issue, template incomplete); root cause + fix shape preserved here on `dev`. - **Cloud + Orca Cloud preset resolver: pin `type` and `from` to CLI-accepted values (#1712 follow-up, reported by maziggy on the Mecha Mewtwo slice)** — Removing bundle mode (entry above) routed every slot through the cross-tier preset resolver. Cloud-tier presets surfaced two latent shape mismatches that bundle dispatch had been masking by materialising preset JSONs from `.bbscfg`-on-disk. (1) **`type` field**: Bambu Cloud labels presets with `type: "printer"` / `"print"` / `"filament"`, but the BambuStudio CLI's `--load-settings` parser only accepts `"machine"` / `"process"` / `"filament"`. The user's first failing slice produced `operator(): unknown config type print of file preset.json in load-settings` with exit code -5; the sidecar surfaces this as a generic "The input preset file is invalid and can not be parsed." (2) **`from` field**: Bambu Cloud's filament detail endpoint routinely ships presets with empty `from` (or no `from` at all). The CLI's compatibility check rejects either with `operator(): file ... 's from unsupported` (the double space in stderr = empty value). Same -5 exit, same generic "input preset invalid" surface. The sidecar's `normalizeFromField` already maps `"User"` / `"System"` → `"system"`, but it doesn't touch empty / missing values. **Fix:** `_resolve_cloud` and `_resolve_orca_cloud` now force `type = _SLOT_TO_PROFILE_TYPE[slot]` and `from = "system"` on the payload before `json.dumps`, mirroring what `_resolve_standard` already does for the standard-tier stub. Both fields are unconditionally rewritten — idempotent on already-correct payloads, and pinning to "system" is consistent with how Bambuddy presents these post-flatten presets to the CLI (no parent walk needed, the cloud detail comes back fully expanded). **Tests:** new `test_cloud_rewrites_type_field_for_cli` (7 parametric cases covering all six type-name variants Bambu Cloud emits plus the missing-type case), `test_cloud_pins_from_field_to_system` (4 cases: empty, already-system, GUI User, GUI System), and `test_cloud_synthesises_from_field_when_missing` (the actual Mecha Mewtwo failure shape) pin the resolver-level contract. Existing happy-path assertions for `_resolve_cloud` / `_resolve_orca_cloud` updated to include the new fields. 28/28 preset-resolver tests green, full backend suite 5919/5919 green, ruff clean. **What this can NOT recover:** if Bambu Cloud later starts emitting a `from` value other than empty / "User" / "System" that genuinely means something (e.g. "project"), Bambuddy will silently flatten it to "system" too. We accept that trade-off because the alternative is leaving "input preset invalid" failures on every cloud slice, and "system" matches how the sidecar's own resolver normalises the post-flatten state. - **Virtual printer cache drained capability/lifecycle fields between pushalls, greying out Device-tab UIs (#1622 round 4, reported by @shaddowlink)** — Reporter on a P1S in archive mode saw the AMS-slot filament dropdown empty and the "Manage calibration data" UI disabled in BambuStudio's Device tab, while the same panels worked correctly on his H2D. After three rounds of triage on the printer-side payload (which traced clean — bridge passes `vt_tray` byte-identical, `tray_info_idx` resolves, AMS slots populate), the actual asymmetry surfaced in the bridge cache dumps: P1S cached `print` state contained 17 top-level keys; H2D contained 99. The missing fields were exactly the capability/lifecycle gates BambuStudio reads to decide which Device-tab UIs to enable (`cali_version`, `print_type`, `gcode_state`, `mc_print_stage`, `mc_stage`, `device`, `cfg`, `home_flag`, the `mc_*` family, fan speeds — ~80 fields). **Root cause:** Bambu firmware sends a full top-level field set in pushall responses (on `pushall` request / printer reconnect) and ~1 Hz incrementals carrying just what changed (typically temps, fan, wifi, status). `_on_printer_raw` in `mqtt_bridge.py` cached the latest push as `new_state = copy.deepcopy(print_data)` — replacing the prior cache wholesale — then re-merged only a hand-picked allowlist (`_SLICER_VISIBLE_STICKY_KEYS`) of 14 keys back from prev. The allowlist covered the #1371 / #1387 / #1228 / #1558 failure modes but missed capability/lifecycle fields entirely, so every 1 Hz incremental drained ~80 fields out of the cache and the slicer's gated UIs flipped off as soon as the cache thinned. The code comment claimed the cache "mirrors the same preservation pattern Bambuddy uses for its own internal state in bambu_mqtt.py" but it didn't: internal state is updated per-field (`if "X" in data: self.state.X = ...`), never drops what it's seen, and accumulates monotonically. **Fix:** replace the allowlist-preserve with per-field accumulate. For every key in the prior cache, carry over verbatim when the incoming push omits it; let new values overwrite when present. The `_merge_ams_dict` deep-merge for partial `ams` blobs stays (#1387 / #1371 regression guards still pass). `_SLICER_VISIBLE_STICKY_KEYS` is removed entirely — the new logic is a strict superset of every case the allowlist handled. **Why most P1S users don't hit it:** timing. The typical workflow is connect → BS issues pushall → cache fills → click Device tab within seconds → UI works. shaddowlink's sequence kept BS idle long enough between pushalls that the cache thinned to incremental-only state before he clicked. X1C users hit the same drain but don't notice — older BS capability spec doesn't gate the same UIs on `cali_version` / `mc_print_stage`. H2D escaped detection because his captures happened to land close to a pushall reply (cache still fat). **Tests:** new `test_incremental_push_preserves_non_allowlisted_capability_fields` regression case in `test_vp_mqtt_bridge.py::TestPushStatusCache` constructs a full push with `cali_version` / `print_type` / `gcode_state` / `mc_print_stage` / `mc_stage` / `device` / `cfg` / `home_flag`, follows it with a temps-only incremental, and asserts every capability field survives. All 51 existing bridge cache tests stay green — same behaviour for the allowlist subset, plus the formerly-dropped fields. Bridge code path; no migration, no new i18n keys. - **Force-color-match checkbox missing when scheduling against a specific printer (#1717, reported by @SamNuttall)** — The Print Queue's schedule dialog hides the per-slot "Force color match" checkbox in the "Specific printer" path. Picking "Any A1" (model-mode dispatch) renders `FilamentOverride` which carries the checkbox, but picking a single printer renders `FilamentMapping` instead — a separate component that had no force-match UI. The dispatcher in `print_scheduler.py:535` already honours `force_color_match` regardless of how the queue item was created (the flag survives end-to-end on the `filament_overrides` payload `buildFilamentOverridesArray` constructs in `PrintModal/index.tsx:613`), so this was a pure UI gap — printer-mode users could not request the safety guard from the modal even though the backend would have respected it. **Fix:** `FilamentMapping` accepts new optional `forceColorMatch` + `onForceColorMatchChange` props mirroring `FilamentOverride`'s shape; it renders the same ``-iconed checkbox under each filament row when a handler is provided. `PrintModal/index.tsx:1100` passes the existing `forceColorMatch` state and a `setForceColorMatch` setter through — same state object both modes write into, so toggling between modes preserves what the user selected. No new i18n keys (the existing `printModal.forceColorMatch` key already ships in all 11 locales). The checkbox is suppressed when no handler is wired (avoids dead UI in callers that don't manage the flag). **Tests:** new `renders the per-slot force-color-match checkbox in printer mode (#1717)` case clicks the checkbox and asserts `onForceColorMatchChange(slotId, true)` fires; companion `omits the force-color-match checkbox when no handler is provided` case pins the absent-handler branch. Existing FTS dropdown-filter tests stay green. `FilamentMapping.test.tsx` 4/4 green; combined PrintModal + FilamentOverride + FilamentMapping suite 73/73 green; eslint clean; frontend build clean; i18n parity 5120 leaves × 11 locales green. - **In-app updater fails when DATA_DIR is on a separate mount from the install (#1715, reported by @francescocozzi)** — Native installs that follow the systemd template `WorkingDirectory=/opt/bambuddy` with `Environment="DATA_DIR=/srv/bambuddy/data"` (or any layout where `DATA_DIR` is not a subdirectory of the install path) couldn't apply in-app updates. Every git step in `_perform_update` (`remote get-url`, `remote set-url`, `fetch`, `reset --hard`) used `cwd=settings.base_dir`, and `safe.directory` was pointed at `base_dir` too. On the standard install (DATA_DIR=INSTALL_PATH/data) this happened to work by accident — git walks up from a subdirectory of the repo to find `.git` — but on a separate-mount layout the data dir is not under the install, the walk-up has nowhere to go, and every operation returns "fatal: not a git repository." Even on the standard install `safe.directory={base_dir}` was wrong (it must equal the repo root git discovers, not the data dir), surfacing on hardened systemd units as "fatal: detected dubious ownership." **Fix:** route every git subprocess in `_perform_update` and `_origin_points_at_repo` through `cwd=settings.app_dir` (the working tree), and set `safe.directory={app_dir}` to match. `app_dir` is now resolved once at the top of `_perform_update` instead of lazily re-resolved before the pip step. The `base_dir` parameter on `_origin_points_at_repo` is renamed to `app_dir` so the signature documents the contract. The pip-install step keeps `cwd=app_dir` (unchanged — that step was already correct). **Tests:** new `test_perform_update_runs_git_in_app_dir_when_data_dir_on_separate_mount` integration case constructs a sibling-paths layout (`tmp/opt/bambuddy` + `tmp/srv/bambuddy/data` — the exact #1715 shape), mocks `asyncio.create_subprocess_exec` to capture every call's cwd, and pins (a) every git subprocess runs with `cwd=app_dir`, (b) the embedded `safe.directory=` config equals `app_dir` on every git call. The existing pip-cwd test stays green (pip's cwd was already `app_dir`). Existing SSH-origin-preserve + origin-rewrite + reset-target tests stay green (they don't assert on git cwd). Full `test_updates_api.py` 21/21 green; ruff clean. **Credit:** root cause + fix shape from francescocozzi via PR #1716 (couldn't be merged as-is — that branch had drifted off an older `dev` and pulled in unrelated upstream commits including a version regression). - **SliceModal preset-lookup precedence + cross-tier dedup + signed-out banner (#1712, reported by @IndividualGhost1905)** — After the Orca Cloud integration shipped (2026-06-04), every user — including Bambu-Cloud-only / Bambu-Studio-preferred users — got Orca Cloud as the top tier across the SliceModal preset picker, the per-preset auto-pick scoring, the dropdown's optgroup rendering, the AMS slot picker's filament sort, and the backend dedup precedence. A Bambu-Cloud / X1C user reported seeing his Bambu Cloud profiles disappear from auto-pick because Orca Cloud's empty tier shadowed them. The cross-tier dedup (introduced with #1150 and inherited as-is by the Orca change) compounded the problem: a name that existed in multiple tiers showed in only ONE group, so a user with a local-imported and Orca-synced "Bambu PLA Basic" never saw the Orca copy as a picker option — even though they curate both sources. And the cloud-status banner (`CloudStatusBanner`) nagged signed-out users with a permanent *"Sign in to Orca Cloud (Profiles → Orca Cloud) to see your Orca presets"* at the top of every SliceModal open — even after a user had explicitly logged out of Orca Cloud. The Bambu Cloud banner had the symmetric problem. **Fix — order:** precedence is `local > orca_cloud > cloud > standard` across `SliceModal.tsx` (`SLICE_MODAL_TIER_ORDER` + `TIER_BONUS` + dropdown tier list), `ConfigureAmsSlotModal.tsx` (`sourceOrder`), and docstrings in `slicer_presets.py` / `schemas/slicer_presets.py` / `client.ts`. Local imports win (the user did them on purpose), Orca Cloud comes next, Bambu Cloud, bundled fallback last. The order drives auto-pick + visual group order; it does NOT hide profiles. **Fix — no dedup, full lists:** `_dedupe_by_name` is replaced by `_enrich_cloud_metadata`, which returns every tier's full preset list across all three slots (printer / process / filament) — a name in local AND orca_cloud AND cloud renders in EACH of their groups so the user can pick any source. The only work the function still does is filament-metadata backfill: a Bambu Cloud filament without its own `filament_type` / `filament_colour` inherits values from a same-named local / orca_cloud / standard entry so it can still score in `pickFilamentForSlot`. Printer + process presets carry their metadata inline and need no enrich. Frontend code already iterates tiers in priority order and surfaces every entry — no change needed there once the backend stops filtering. **Fix — banner:** `CloudStatusBanner` now silently returns null on `not_authenticated` in addition to `ok` — applies symmetrically to both Bambu and Orca cloud banners. `expired` (token broke) and `unreachable` (network / service down) still surface — those are real breakage states a previously-signed-in user needs to see. Sign-in lives on the Profiles page; the modal doesn't need to advertise it. The `slice.cloud.notAuthenticated` / `slice.orcaCloud.notAuthenticated` i18n keys stay in the locale files (dormant) so re-enabling later doesn't need a re-translation pass. **Fix — ConfigureAmsSlotModal source badges:** before this change, the per-row source badge fired three branches independently — `local` got a green "Local" badge, `builtin` got an amber "Built-in" badge, and a blue "Custom" badge appeared on top of those when `isUser` was true. Since ALL Orca Cloud entries are marked `isUser: true` and Bambu Cloud user presets also get the same flag, the result was visually inconsistent: Orca Cloud rows showed *only* "Custom" (no source identification, no way to tell them from Bambu Cloud user presets), Bambu Cloud built-in rows had NO badge at all, and the "Custom" badge collided with the actual source. Replaced with a single source badge per row: green "Local", purple "Orca Cloud" (new), bambu-blue "Bambu Cloud" (new), amber "Built-in". One badge per row; one colour per source; the `isUser` distinction within the Bambu Cloud tier is dropped (the preset name itself carries the "is this user-authored" signal). Same change in both render blocks (the filament-list code is duplicated in the modal — kept the duplication local rather than refactoring out a helper component in this PR to keep the diff tight). i18n: 2 new keys (`configureAmsSlot.orcaCloud`, `configureAmsSlot.bambuCloud`) translated to all 11 locales — both are brand names, already on the per-locale `IDENTICAL_TO_EN_ALLOWED` lists so the parity check is satisfied without per-locale variants. The dormant `configureAmsSlot.custom` key stays in the locale files. **Tests:** `TestEnrichCloudMetadata` replaces `TestDedupeByName` (5 cases): regression guard pinning that a name in all four tiers appears in EACH (not just local), tier order preserved within a tier, Bambu Cloud filament metadata backfilled from local, backfill falls through to orca / standard when local doesn't carry the name, backfill does NOT overwrite Bambu Cloud's own metadata when present. The "renders a sign-in banner when cloud_status is not_authenticated" case flipped to assert no banner appears, with the test name updated to call out the #1712 reason. Backend `test_slicer_presets.py` 47/47 green; `SliceModal.test.tsx` 34/34 green; `ConfigureAmsSlotModal.test.tsx` 24/24 green; ruff clean; frontend build clean; i18n parity 5120 leaves × 11 locales green. - **Telegram (and other image-bearing) finish notification on a reprint-from-archive showed the original print's finish photo instead of the new run's (#1707, reported by @kycrna)** — P2S user reprinted an archived job and observed the Telegram notification arriving with the photo of the *original* print (white box) attached to the completion message for the *new* run (black box). **Root cause:** reprints reuse the source archive row — `register_expected_print` stores the source `archive_id` in `_expected_prints`, and the on-print-start expected-archive promotion branch at `main.py:2207-2245` updates the row's status / started_at / printer_id / subtask_id but never reset `archive.timelapse_path`. Two failure modes cascaded from the stale path: (a) `_scan_for_timelapse_with_retries` early-returns at `main.py:3062` with `if archive.timelapse_path: return` — the reprint's new timelapse MP4 sitting on the printer's SD card was never downloaded, the archive's `timelapse_path` kept pointing at the original run's local file; (b) `_capture_finish_photo_from_timelapse` polls `archive.timelapse_path` and immediately found the *original* video, extracted ITS last frame as `finish__.jpg`, and handed those bytes to `_background_notifications` as `image_data` — which then went out to Telegram via the `sendPhoto` path. The filename was new (so log lines and the archive's `photos` list looked correct), but the pixels were the original run's finish frame. Surface was specific to the timelapse-prefer path: with `data.timelapse_was_active` true and no external camera, `prefer_timelapse_source` was True, which is the exact configuration on P2S with timelapse-on for both runs. External-camera, buffered-frame, and fresh-RTSP fallback paths grab the *current* camera frame, so users on those paths saw correct photos and the bug stayed hidden. **Fix:** at expected-archive promotion, capture and clear `archive.timelapse_path` to None before the commit, and `os.unlink` the stale on-disk video so reprints don't accumulate orphaned MP4s in the archive directory. Photos list is left alone — accumulating one finish photo per run across the archive's lifetime is the right behaviour. The unlink is wrapped in `OSError`-catching best-effort logging so a missing file (manual delete, archive purge, container rebuild with bind-mount drift) doesn't break promotion. The clear-and-unlink runs unconditionally when `timelapse_path` is set, so even if a user has been reprinting under the buggy build for months, the next reprint self-heals. **Tests:** 3 new cases in `test_reprint_clears_stale_timelapse.py` exercise the full `on_print_start` callback through the expected-archive branch — happy path (path cleared + file unlinked), no-prior-timelapse (no-op, promotion still succeeds), missing-stale-file (best-effort unlink doesn't raise). Full `test_print_start_expected_promotion.py` + `test_print_start_assigns_printer_id_to_vp_archive.py` suite (28/28) stays green; ruff clean. - **Connection diagnostic no longer flags `external_storage: fail` on A1 / A1 Mini, which physically have no MicroSD slot (#1703, reported by @MartinNYHC)** — Bug report from an A1 user complained that BambuStudio and OrcaSlicer don't have an "external storage" tick box (correct — there's nothing to toggle, the A1 series ships without a SD card slot at all) while the Bambuddy support bundle simultaneously reported `external_storage: fail` in the printer's connection diagnostic. The two together left the user thinking Bambuddy was wrong about a setting their hardware doesn't have. **Root cause:** the `external_storage` check at `services/printer_diagnostic.py:179-189` reads `state.store_to_sdcard`, which is parsed from MQTT `home_flag` bit 11. On A1 and A1 Mini that bit is never set (no hardware slot, no firmware-side toggle, no slicer-side equivalent), so the value pushes as `False` and the check fell through to `fail` instead of `skip`. **Fix:** new `NO_EXTERNAL_STORAGE_MODELS` frozenset in `utils/printer_models.py` enumerating A1, A1 Mini, and their internal codes (N1, N2S, A04, A11, A12), plus a `has_external_storage(model)` helper that returns False for those and True for everything else (unknown models default to True so the check stays active for any future Bambu model that ships *with* a slot — new no-slot models must be added to the set explicitly). The diagnostic now short-circuits to `skip` before reading `store_to_sdcard` when `printer.model` is in the set. **What this does NOT change:** X1 / X1E / P1S / P1P / P2S / H2D / H2D Pro / H2C / H2S / X2D continue to evaluate `store_to_sdcard` exactly as before — the home-flag-bit-off → `fail` path is still the right signal for them. **The companion FTP-upload-timeout symptom in the same bug report (ftp code 28 from BambuStudio when sending to the proxy VP) is a separate Docker-bridge-mode networking constraint, not addressed by this change.** **Tests:** 8 new cases — `TestHasExternalStorage` (5 cases) pins the model list, internal-code aliasing, case/whitespace normalisation, unknown-defaults-true, and null/empty-defaults-true; `TestExternalStorageCheck` gains `test_skips_on_a1_no_external_storage_slot`, `test_skips_on_a1_mini_no_external_storage_slot`, and `test_still_fails_on_x1c_when_toggle_off` (regression guard that the model-aware skip doesn't accidentally silence the genuine signal on slotted models). Full `test_printer_models.py` + `test_printer_diagnostic.py` + archives integration suite green (172/172); ruff clean. - **AMS slot card surfaced the previous spool's preset name after RFID auto-assigned a new spool (reported with H2D-1 / AMS-B3 / PLA-CF showing as "Bambu PLA Silk+")** — Reporter inserted a fresh Bambu PLA-CF spool into AMS-B3, RFID identified it correctly, but the slot card kept showing "Bambu PLA Silk+" (the name from a PLA Silk+ spool that had occupied the slot back in March). Confirmed in the live data: `slot_preset_mappings` row for `(printer_id=1, ams_id=1, tray_id=2)` was `preset_id=GFSA06_09, preset_name='Bambu PLA Silk+', updated_at=2026-03-15` — three months stale. **Root cause:** `slot_preset_mappings.preset_name` is first in the PrintersPage display chain (`PrintersPage.tsx:3624`) and overrides the spool's own `slicer_filament_name` plus the cloud catalog `cloudInfo.name`. The internal-mode manual-assign path (`inventory.apply_spool_to_slot_via_mqtt`) kept this row in sync, but the internal-mode RFID auto-assign path (`spool_tag_matcher.auto_assign_spool`) skipped it entirely. The Spoolman-mode sync path (`main.auto_sync_spoolman_ams_trays`) also skipped it — same bug shape, latent for Spoolman users who'd never manually configured a slot preset, active for those who had. **Fix — three writers in lockstep via one shared helper.** New `backend/app/services/slot_preset_writer.py` exposes a primitive `upsert_slot_preset` plus two convenience wrappers: `upsert_slot_preset_for_spool` for internal `Spool` ORM objects (local-preset numeric ids → `local_{n}`, cloud ids run through `filament_id_to_setting_id`) and `upsert_slot_preset_for_spoolman_spool` for Spoolman dicts (filament.name → preset_name, tray_info_idx → preset_id). All three call sites — the manual-assign block in `inventory.py:396-438`, the RFID auto-assign tail in `spool_tag_matcher.py:auto_assign_spool`, and the per-tray-sync branch in `main.py:auto_sync_spoolman_ams_trays` — now go through the helper. **Self-heal:** existing stale rows from past spool swaps get rewritten the next time a fresh spool is detected on the same slot. No migration script needed. **What this also covers per `feedback_inventory_modes_parity`:** the bug shape exists in both internal and Spoolman modes, so the patch ships fixes for both inventory paths in the same drop — a Spoolman user with a manually-configured slot preset would have seen the same stale-name behavior after every RFID swap until the row was overwritten through Configure Slot. **Tests:** new `test_slot_preset_writer.py` (6 cases) pins the helper contracts — no-op on empty preset_id, upsert idempotency, Spoolman filament.name → preset_name, fallback to material → tray_sub_brands → tray_type, stale-row overwrite from the Spoolman path, skip when tray_info_idx is unknown. New `test_spool_tag_matcher.py` cases (3) pin the internal RFID-auto-assign path — stale-row overwrite (the exact reporter shape: PLA Silk+ → PLA-CF), fresh insert when no row exists, `local_{n}` formatting for numeric local-preset ids. Total touched-area suite 69/69 green; broader related suite (inventory + spoolman + spool_tag + auto_sync) 767/767 green; ruff clean. - **Stats page Failure Analysis widget rendered raw camelCase keys instead of translated reasons (#1687 follow-up, reported by @IndividualGhost1905)** — After #1687 part 4 shipped the per-row Print Log editor, the reporter classified a couple of failed runs and saw "filamentRunout" / "cloggedNozzle" (the literal camelCase keys) appear under Statistics → Failure Analysis → Top Failure Reasons, while the same rows rendered correctly as "Filament runout" / "Clogged nozzle" on the Print Log table. Surfaced an inconsistency I introduced when shipping the new editor: the new Print Log row editor saves the camelCase key (`filamentRunout`) which is what the new backend PATCH validates against, but the older `EditArchiveModal` was still saving the localised label (`"Filament runout"`) as the value — two formats landing in the same `PrintLogEntry.failure_reason` column from two different UI surfaces. The Failure Analysis widget at `frontend/src/pages/StatsPage.tsx:817` and the per-archive run history sub-table at `frontend/src/components/PrintLogTable.tsx:81` both rendered the raw column value without running it through i18n, so the new key-form values surfaced as literal keys. **Fix — three sites in one drop:** (1) `StatsPage.tsx` and (2) `PrintLogTable.tsx` now wrap the value in `t('editArchive.failureReasons.${reason}', { defaultValue: reason })` — same pattern already used at `ArchivesPage.tsx:3874` for the Print Log table. The `defaultValue` fallback keeps legacy translated-text rows rendering as-is, no regression. (3) `EditArchiveModal.tsx` now saves the camelCase key (`