Updated CHANGELOG

This commit is contained in:
maziggy
2026-08-28 12:58:25 +02:00
parent 029c3ac4b7
commit f1096c13eb
+25
View File
@@ -5,6 +5,10 @@ All notable changes to Bambuddy will be documented in this file.
## [1.2.5.4] - Unreleased
### Added
- **Dutch (nl) is now a supported interface language (#2891, requested and contributed by @Igiegel)** — Adds `nl` as the fourteenth locale, listed as "Nederlands" in the language picker. The translation was contributed as a file on the issue and needed three corrections before it could be wired up, all of which the parity gate found. First, the nine `stats.timeframe.*` entries had their **keys** translated along with their values (`'today'` had become `'vandaag'`), which would have left the Statistics timeframe selector resolving nothing and rendering raw key names for every Dutch user — the values were kept and the keys restored. Second, the file was translated against an older `en.ts` and was 84 leaves short, missing the Filament Track Switch feed prompts, the AI-detection status strings, the no-3MF internal-history banner, the batch-order stranded-plate notices, the Avery starting-position field and the whole `locationHaSensors` section from #2824; those were translated and added. Rather than splice them in, `nl.ts` was regenerated from the `en.ts` skeleton with the contributor's strings carried over, so its structure, key order and section comments now match the reference locale exactly and a future diff against `en.ts` reads as content rather than as reordering. Third, 229 leaves were identical to English; each was checked individually and all were kept, because Dutch takes most technical UI vocabulary verbatim — printer, filament, status, nozzle, timelapse, dashboard — and Dutch slicer users use the English feature names (support, ironing, prime tower, gap fill) untranslated. Those 123 distinct values are now listed explicitly in a `NL_COGNATES` allow-list in `check-i18n-parity.mjs`, the same shape the other twelve locales use, so the exemption is an enumerated translator decision rather than a blanket skip. Parity green at 6264 leaves across all 14 locales.
- **A spool can carry a different filament preset on each printer model, and its K profiles are picked per hotend** — A slicer preset is bound to a printer model: `Bambu PLA Basic @BBL X1C` is not the same preset as `@BBL H2C`. A spool stored exactly one, which was right until the same spool was used on a second model — the AMS slot on the other machine was then configured with a preset that machine has no profile for. The spool form's PA Profile tab is now a **Printers** tab holding both halves of the answer: a model list on the left, and on the right that model's filament presets and the K profiles for each of its hotends. Presets are keyed on the printer *model*, because `@BBL X1C` is the same preset on every X1C you own and asking once per machine would mean picking the identical value twice; K profiles stay keyed on the individual printer, extruder and nozzle diameter, because a K value is measured on one physical hotend and two machines of the same model legitimately differ. Both halves cover **every nozzle size — 0.2, 0.4, 0.6 and 0.8 — not only the size currently fitted**, because a spool is configured once and nozzles get swapped: presets get a row per size (the preset is written to an AMS slot, a slot feeds exactly one nozzle, and Bambu names its presets per size anyway), and K profiles are laid out as a grid with size down the side and hotend across the top, a dash marking a size the printer has no calibration for. Anything left alone inherits the spool's own preset and keeps inheriting it when that changes later, so only what actually differs needs an override, and a spool nobody has configured behaves exactly as it did before. Each model is offered only the presets that name it — using the same matcher the Configure AMS Slot modal filters with, now shared between them — while presets whose name identifies no model, which is most user-authored and OrcaSlicer ones, stay available everywhere, and a preset already saved is never hidden from the control that shows it. Every preset carries an origin badge — Bambu Cloud, Orca Cloud, Local or Built-in — in the same wording and colours the Configure AMS Slot modal has used since #1623, because the same filament exists in several of those sources and which one is picked decides what actually reaches the printer. **Auto-match** fills every size of every model with the variant of the spool's preset that names it, preferring the variant for that exact size; a model with no such variant is left inherited rather than given an approximate one. The model list stays one row per model however large the fleet is, and carries a count of hotends still without a K profile so an unfinished spool is visible without opening anything. One limit worth knowing: a per-model override can be one of your own cloud presets, whose id the slicer refuses in a slot's filament field, so such an override configures the slot but is not used as the calibration link — the spool's own preset is used there instead.
- **K profiles distinguish High Flow from Standard nozzles** — A printer files each calibration under a nozzle id of the form `HH00-0.4` (high flow) or `HS00-0.4` (standard) and can hold both for one diameter — a maintainer's H2D carries 102 high-flow entries and 6 standard — because the same filament reads a different K through each. The picker labels every profile with the flow it was measured on, saving records it, and a stored profile is no longer applied when the fitted nozzle disagrees; the picker marks such a profile rather than letting it look configured while quietly doing nothing. Two spellings have to agree for that: a calibration entry says `HH00-0.4` while the fitted nozzle reports `HH01`, so the comparison is two characters, not four. An unknown flow on either side matches anything, which is what it must do — every K profile stored before this has none, and an X1C declares none on any profile at all (measured: all eight come back with an empty nozzle id) even though the machine really does take either nozzle, so treating silence as "Standard" and filtering on it would have dropped every X1C profile the moment a high-flow nozzle was fitted.
- **Every path that configures an AMS slot respects a spool's per-model preset and per-hotend K profile** — The manual assign in either inventory mode, the RFID auto-assign, the Spoolman tag link, the re-fire when a slot goes from empty to loaded, the re-apply after a calibration-table refresh, and the re-selection when a Filament Track Switch moves an AMS to the other nozzle. The Configure AMS Slot dialog also opens on the spool's own configured values, falling back to the slot's last manual configuration and then the tray's RFID data, rather than ignoring what the spool was configured with on the one screen that looks like it exists for it. A printer card in expanded view now lists every fitted nozzle size rather than the first entry alone, which on a machine with two different sizes named one hotend and implied it was the whole printer.
- **An Avery sheet can start at the first unused position, so a part-used sheet is not thrown away (#2879, requested and contributed by @whitigol in #2918)** — Label PDFs always began in the top-left slot, so the second batch printed onto a sheet that already had seven labels taken off it would have printed over the gaps. Spool labels are printed a few at a time as filament arrives, which meant spending a 30-slot Avery 5160 sheet on two labels or nothing. A **Starting label position** field in the print-label dialog now says which slot to begin at, counted the way the sheet reads — left to right, top to bottom, starting at 1. The offset applies to the first page only and later pages restart at slot 1, because the number describes the sheet already in the tray rather than anything about the job: the second sheet the printer pulls is a fresh one. Position 1 is the default and is what a request that omits the field means, so both label endpoints — built-in inventory and Spoolman — behave exactly as they did for anyone who never touches it. The bounds are per template and enforced on both sides, 1–21 for Avery L7160 and 1–30 for Avery 5160; the server derives them from the sheet layout table it already lays labels out from rather than carrying a second copy of the numbers, so the two cannot drift, and a non-default position is refused outright for the single-label roll templates where a sheet slot means nothing. In the dialog the two sheet buttons disagree about the same number — 25 is valid on a 5160 and off the end of an L7160 — so the button whose capacity the value exceeds is disabled and its hint is replaced by that sheet's own range, instead of leaving a disabled button next to guidance that says the value is fine. The sentence naming the skipped slots deliberately avoids i18next's reserved `count` variable: with no `_one` form defined, a value of 1 resolves past the translation to the English default, so every non-English locale would have printed an English sentence at position 2 and only at position 2. Translated in all 13 locales, README and wiki updated, and covered by renderer, endpoint and dialog tests including the page-boundary cases where the selection exactly fills the offset first sheet and where it runs one past it.
- **A fault's description is in the status response, so a client no longer needs its own copy of the table (#2926, proposed and analysed by @sadontsev)** — `HMS_ERROR_DESCRIPTIONS` has been in the backend all along and the status response never carried it, so every consumer that wanted to tell a user *why* a print halted resolved the same 853 codes from its own duplicate of the same sentences — this repo's Python table, the frontend modal's, and at least one third-party iOS client whose catalogue exists purely because the server would not say. Each aged separately, and a push relay watching a printer could only manage "your printer needs attention" while the server already knew it was "Filament ran out. Please load new filament." `hms_errors[]` entries now carry `description`, defaulting to null so a client that has never seen the field is unaffected. It is resolved once, where the fault is parsed, rather than at the boundary that happened to prompt the request: there are three separate serializers of a fault — the status response, the WebSocket broadcast, and the print-completion payload the queue's failure reason is built from — and adding it to only the first would have delivered half the feature to a relay watching the stream, which is the likelier consumer. The queue's failure reason now quotes the same sentence instead of resolving the code a fourth time, and the notification path reads it rather than re-deriving its own. Resolving in one place is also what makes the three unable to drift, which is pinned by a test that asserts they agree. Resolution is exactly what the codebase already did, verified rather than assumed: an 8-char `print_error` is the catalogue's `MMMM_EEEE` key split in half, and a 16-char `hms[]` identifier is tried whole and then collapsed to its first and last groups, which is how the notification path, the queue's failure-reason helper and the frontend modal have always resolved those. The collapse is lossy — #2728 counts 65 documented faults falling onto `0300_0001` alone — and it is kept rather than tightened here because refusing it would not read the same data more strictly, it would stop describing faults that are described today and leave this field null while the UI shows text for the same fault. Narrowing it belongs with #2728, where both key spaces can move together. A fault the catalogue does not cover reports null and is still reported in full; `full_code` identifies it either way. The equivalence is pinned by a test that checks every catalogue code in both fault shapes across all three alert levels, so a future change to the lookup cannot silently stop notifications from firing. The text is English only and unlocalized, which the schema says next to the field. The frontend keeps resolving its own text for now; switching it over would change what `filterKnownHMSErrors` counts across eight call sites, which is #1840 and #2728's argument rather than this one's.
- **A virtual printer can be told which address to advertise, so uploads work on Docker bridge networking (#2930, reported and diagnosed by @sebimarkgraf)** — `VIRTUAL_PRINTER_ADVERTISE_ADDRESS` sets the address written into the MQTT status that BambuStudio and OrcaSlicer read their FTP upload destination from. It exists for deployments where that address is not one of the container's own interfaces: on bridge networking the virtual printer is reached on the host's LAN IP but binds a private one like `172.24.0.2`, and that private address is what the slicer was handed — so it opened an FTP connection to an address that does not exist on its network, which is the upload stalling around 10% with "Failed to send" that the troubleshooting page has been describing as a limitation with no fix. Set it to the address slicers use, alongside the `VIRTUAL_PRINTER_PASV_ADDRESS` that already existed for the passive-data channel. The log line that arms the rewrite names its source, so `(VIRTUAL_PRINTER_ADVERTISE_ADDRESS)` versus `(bind_address)` says whether the variable reached the container. A value that is not a dotted-quad IPv4 is refused with one warning naming it and the address that would have been used before is used instead — deliberately, because refusing to rewrite at all would put the *real printer's* IP back in front of the slicer, which is the leak this path exists to close and strictly worse than the wrong local address. `0.0.0.0` counts as unset, and surrounding whitespace is tolerated for the sake of values pasted into a compose file. This is an environment variable rather than a change to how the advertised address is resolved, and that was the decision worth making carefully: the virtual printer already has a "Network Interface Override" field, but it feeds SSDP and the certificate's SAN list only, and reading it here would have moved the upload destination on every install that has one set — the multi-NIC, VLAN and Tailscale setups, which are the ones most likely to have been arrived at by hand and the least likely to survive being second-guessed. Unset, nothing about the resolution changes, which is pinned by a test. Host and macvlan networking still need none of this and remain what Virtual Printer is developed against; the variable removes one blocker rather than making bridge mode equivalent, and the wiki now says so in the three places that previously stated the host address could not be discovered at all.
@@ -14,6 +18,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Filament Track Switch: the inlet each AMS feeds, and K-profiles that follow it** — With a switch fitted an AMS is not wired to a nozzle any more. It is plumbed into one of the switch's two inlets and reaches both hotends through it, so every unit reports its extruder as "not fixed" and `ams_extruder_map` comes back empty. Bambuddy had nothing to fall back on but the AMS unit number, so AMS-A was badged R and AMS-B was badged L purely because their ids are 0 and 1, a third unit got no badge at all, and every one of those labels was wrong; the SpoolBuddy assign modal had the same fallback in a worse form, mapping anything that was not extruder 1 to R. The binding needed no new telemetry — it sits in bits 24-27 of the same AMS info string Bambuddy already parses for the unit's type and extruder id, and is read only when a switch is installed, because without one "not fixed" really does mean an uninitialised unit. The badge keeps L and R, in its own colour, with the inlet named in full in the tooltip, since the letter is the inlet's position and not a claim about which nozzle that AMS feeds: the switch can route either inlet to either outlet. Both views update live, which took adding the switch fields to the WebSocket payload and to the broadcast dedup key — the binding is not part of the AMS change hash and must stay out of it, because that hash drives Spoolman sync. The calibration half is where it bites. K-profiles are numbered per nozzle, so the same index means a different profile on each hotend, and a tray holds exactly one index: move an AMS to the other inlet and every configured slot silently kept pointing at the old hotend's table. Measured on an H2C, a black PLA calibrated 0.018 left and 0.020 right stayed on the left profile after the move, and a manual RFID re-read only re-asserted the same wrong one. Three separate copies of "which extruder is this slot on" each ended in `else 0`, which on a switch machine filed every profile under the right-hand nozzle; they now share one resolver that returns unknown as its own answer, because unknown and extruder 0 are very different things on a dual-nozzle machine. Moving an AMS now re-selects each configured slot's counterpart profile for the nozzle it has arrived on, and only for spools that already have one there — a slot nobody has configured, or a spool calibrated on one hotend only, is left exactly as the operator set it. Configure Slot resolves against the slot's own nozzle throughout: options name the hotend, a filament calibrated on both gives two distinguishable entries, matches are scoped to the nozzle the slot feeds, and the other hotend's profiles stay reachable under Other K profiles. The print dialog's slot dropdown picks up the same inlet labelling and notes when every filament a print needs sits behind one inlet, which is legal but slow — a change between two spools on the same inlet retracts all the way back to the AMS, where a change across the two only retracts as far as the switch. Assigning an AMS to an inlet stays on the printer: Bambu Studio can read that binding and has no command to write it.
### Changed
- **The spool form is wider, and its colour, weight and cost fields have their own tab** — The Printers tab is a model list beside a detail pane, which needs the room. Colour, spool weights, price, category and storage location move out of the bottom of a long scroll into a **Color & Cost** tab, laid out in two columns rather than one short field per row. Filament identity and the slicer preset stay on the first tab.
- **Home Assistant sensors moved out of the Smart Plugs settings tab into their own (#2824)** — Sensors are things you read and plugs are things you switch, and with storage-location bindings joining the printer ones the two no longer belong on one page. Both now live under **Settings → Sensors**; nothing about the printer bindings themselves changed, only where they are. The template that carried the printer alert is renamed from "Home Assistant Sensor Alert" to "Printer Sensor Alert", along with the toggle and badge labels that name it, because once a storage-location alert existed beside it the old name no longer said which one it was; the rename only touches templates still holding the old default name, so one that was edited keeps whatever it was called. A battery-class printer sensor now draws a battery icon instead of the generic gauge — the printer map never had an entry for it and the shared one does, which reads as the omission it was rather than a choice worth preserving.
- **Storage locations sort the way they are named (#2824)** — The locations list was ordered by `ORDER BY name`, which puts "Drybox 10" between "Drybox 1" and "Drybox 2". It is now sorted on the numbers inside the name, so a rack numbered past nine reads in rack order everywhere the list appears.
- **Camera view mode is picked at the camera button, per printer** — Whether a camera opened in its own browser window or as a floating overlay was one dropdown in Settings → General → Camera, applied to every camera on the install. Deciding it per printer meant leaving the Printers page, changing the setting, coming back, opening the camera, and going back again to undo it. The camera button on the printer card is now a split control: the icon opens the camera whichever way you opened the last one, and the caret beside it offers both modes, with the one in effect ticked. Picking a mode opens the camera that way as well as making it the mode the icon uses from then on, because a menu that only changed a preference would leave you a second click to do the thing you had already asked for. The choice lives in your own browser, so two people watching the same farm can each have the view they want; `camera_view_mode` survives as the default a browser that has never chosen starts from, and is written back when you hold `settings:update`. The Cam Wall follows the same remembered mode, and the popup-opening code the card and the wall each had a copy of is now shared, with a corrupt saved window geometry falling back to defaults instead of throwing. The effect that force-closed every open overlay when the setting flipped to window is gone — it made sense for a global switch, not for a choice made per click. No new locale strings: the four the settings control used are reused as the menu's labels and tooltips.
@@ -24,6 +29,26 @@ All notable changes to Bambuddy will be documented in this file.
- **Generated thumbnails are lit, so one model no longer looks like the next (#2816, requested by @NaegeliJ, contributed by @sadontsev in #2861)** — Both renderers that draw a model themselves — the File Manager's STL/3MF thumbnails and the plate cards — handed matplotlib a mesh with no light source, and without one every triangle is filled with the identical green whichever way it faces. The result was a flat silhouette, so two models of similar outline were the same picture. The mesh is now shaded from a fixed light whose angle is pinned to the camera angle rather than chosen freely: the two are a pair, and a light aimed at the far side of the model gives both visible faces the same brightness and no contrast at all. Lighting then exposed two faults a flat render had hidden. A face wound the wrong way shades as though it faced away, so an STL with inconsistent winding came out patchy like camouflage; winding is now repaired before the render — outward rather than merely consistent, and only for the meshes that need it, since the check is milliseconds where the repair is seconds. And a mesh whose facets are all zero-area or collinear — stub or truncated STLs, 3MFs with an empty triangle list — would have failed outright once lit, so those are detected and still render flat instead of counting as a failure in a folder-wide batch. Both renderers share one copy of the light, the camera and the repair, so a plate card and a library thumbnail of the same model cannot drift apart. Passing the faces as an array rather than a list of lists also cut the collection build on an 82k-face mesh from ~0.19s to ~0.007s, which speeds up the unlit path too.
### Fixed
- **A print archived without its 3MF never got its timelapse, and on a short print could be given somebody else's (#2957 follow-up, reported by @doncaruana)** — The reporter confirmed the #2957 archive recovery worked and then noticed the timelapse was not recovered with it, "even though it's there". `_capture_timelapse_baseline_at_start` says in its own docstring that it must be called from every `on_print_start` path that proceeds to a real print, and what breaks otherwise: the completion scan falls back to snapshotting the card *after* the printer has written the video, so the new file lands inside the baseline and no diff can ever match. `on_print_start` has three such paths and the no-3MF fallback branch was not calling it — nothing else covered the gap either, because `on_print_running_observed` is restart-recovery only and is suppressed whenever `on_print_start` fires. So every fallback archive reached completion with no baseline in memory and none on the row, and kept its timelapse only by accident. Not confined to the reported cool-off case: the same branch serves the internal-storage verdict, so every H2C/H2D/P2S print that Bambu Studio's Print button sends to eMMC lost its timelapse the same way, on a card that was holding it the whole time. Measured on a live install before the fix: 0 of 9 fallback archives had a baseline, against 73 of 275 normal ones. The branch now takes the baseline like the other two, placed last as they are so a slow card cannot delay the active-print registration, the energy reading, the archive-created event or the start notification ahead of it. **The second half is the short-print case.** Under the five-minute FTPS cool-off the card is still unreadable when the baseline has to be taken, and `list_files_async` answers `[]` when its connect fails rather than raising — indistinguishable from a card holding no videos. When the cool-off then expired inside the 900-second poll window, every video on the card read as new, the first in listing order won, and a stale unclaimed video was attached to this print and deleted off the printer. The empty baseline is still recorded rather than refused, deliberately: Bambuddy deletes each video once it is attached, so the usual card holds exactly one video at completion and an empty baseline resolves it correctly — refusing outright would have lost that common case to protect a rare one. Instead the scan marks such a baseline untrusted and the attach step declines to *choose* between several candidates, leaving them on the printer for the manual Scan for Timelapse button. Five regression tests covering both halves and the single-video case; full backend suite green (11230).
- **The print dialog named an AMS slot after the wrong spool** — The slot dropdown described every slot from the printer's own telemetry, and the printer cannot describe a spool it did not sell: a tray record has no brand field, `tray_sub_brands` is left empty for anything that is not a Bambu spool, and the colour is a bare hex the client resolves against Bambu's colour catalogue. A Devil Design PLA Basic Orange assigned in Bambuddy therefore read as "PLA (Sunflower Yellow)" — Bambu sell a Sunflower Yellow at the same `FEC600` — while the printer card, which reads the assignment, named it correctly. The two views now agree: `GET /printers/{id}/inventory-remain` carries each bound slot's brand, material, subtype, colour name and hex alongside the pooling key it already sent, and the dialog prefers that over telemetry, falling back field by field so a spool with no stored colour name still gets the catalogue lookup it had before, and re-reading it on every open so a spool assigned moments earlier is named correctly straight away. Resolved server-side, so internal inventory and Spoolman mode answer identically rather than the client re-deriving a rule that differs per mode. Matching is deliberately untouched and still runs on the printer's telemetry, so the auto-assignment and the colour-mismatch warning cannot start disagreeing with what the dispatcher does.
- **Every page was a blank white screen on iOS 16.0-16.3 (#2971, reported by @zevulos)** — An iPhone on iOS 16 loaded nothing at all: no error, no partial render, just white, over LAN IP and over an HTTPS domain alike, while the same install was fine on Android, macOS, Windows and Linux. The cause was one regular expression. `remark-gfm`, added in v1.2.5 for the folder README panel, reaches `mdast-util-gfm-autolink-literal`, whose module body contains a lookbehind assertion — `(?<=` — that Safari did not support until **16.4**. A regex literal is validated when its module is *compiled*, not when the function holding it runs, so this was never going to fail as a broken README panel: `FolderReadmePanel` -> `FileManagerPage` -> `App` is a plain static import chain, the regex landed in the entry chunk, and the browser refused to compile all 10 MB of it. Nothing executed, so nothing rendered. Every install from v1.2.5 onward has been unusable on those iOS versions, and v1.2.4 is the last release that loads on them. **The fix.** The panel now renders GFM through a locally composed plugin that registers four of `remark-gfm`'s five sub-extensions — tables, strikethrough, task lists and footnotes — and omits autolink literals, which is the only one that carries the lookbehind. Composing rather than configuring is forced by the nature of the bug: importing `remark-gfm` at all is what breaks the page, so no runtime option could have reached it. The visible cost is that a bare `https://example.com` or `foo@example.com` typed into a folder README no longer turns itself into a link; `[text](url)` and `<https://example.com>` are core markdown and still do. `remark-gfm`, `mdast-util-gfm` and `micromark-extension-gfm` leave the dependency tree, and the bundle is 23 KB smaller. **Why the build never said anything.** Vite's `build.target` governs syntax lowering, and esbuild does not rewrite regular expressions — measured here, a lookbehind builds silently under `safari15`, `safari16.0` and `es2020` alike, which is exactly how this shipped and then sat unnoticed for two months. So the guard is a real check rather than a compiler setting: `npm run build` now ends in `check-browser-baseline.mjs`, which scans the emitted bundles for syntax that Safari 16.0 cannot parse and fails the build with the offending snippet and the dependency-hunting command. It is deliberately scoped to *parse-time* failures only — a missing runtime API breaks one feature, while one of these takes down the whole app, and there is no graceful degradation to fall back on. Covered by seven renderer tests that pin both halves of the trade: each surviving GFM feature still renders, and the two forms of autolinking stay off on purpose so a future dependency bump cannot quietly bring the lookbehind back.
- **A failure reason the backend derived and the same reason a user picked counted as two different reasons (#2974, reported by @ojimpo)** — `failure_reason` was written in three vocabularies and nothing reconciled them in storage. The backend wrote English display labels (`"Layer shift"`), older builds of the archive editor wrote the *translated* label in whatever locale that user was running, and two stale-archive paths wrote English prose sentences (`"Stale - reconciled after reconnect, end time unknown"`). All three land in one column — the PATCH route has mirrored the field onto the latest print-log entry since #1444 — and the Failure Analysis widget groups on the raw value, so one real cause occupied several buckets. Measured on a live install before this landed: 91 rows reading `"User cancelled"` beside 1 reading `"userCancelled"`. In an English UI those render as the same words twice with different counts, which is why it went unnoticed; in any other locale one of the two stays English, because a stored label has no key for `t()` to resolve. The editor was worse than cosmetic about it: its reverse lookup compared the stored value against `t(...)` in the *current* locale, so for a non-English user nothing matched, the dropdown opened empty over an archive that plainly showed a reason, and saving from that state wrote the empty selection over the stored text. **One vocabulary now.** The keys were already canonical and already enforced — `_FAILURE_REASON_KEYS` in `api/routes/print_log.py` rejects anything else with a 400, and says why in its own comment — so this is `derive_failure_reason` being brought in line with a rule the rest of the stack had been keeping. `_HMS_FAILURE_REASONS` stores keys, the cancel branch returns `userCancelled`, and both stale paths write a new `noStatusUpdate` key rather than prose; which of the two stale situations occurred is already carried by `status`, so collapsing them loses nothing and gives Statistics one bucket instead of two untranslatable sentences. **Existing rows are converted**, which was the open question on the issue: a startup migration folds 168 historical labels onto the 12 keys across both columns. It is exact rather than a guess — every label across all 14 locales resolves to exactly one key, verified with no collisions — and the map is a frozen snapshot rather than something read from the locale files at run time, because it maps what was written historically and regenerating it would silently stop recognising the very rows it exists to convert. A value outside the map is deliberately left alone; guessing at it would be worse than leaving one honest string in its own bucket. The migration carries no one-shot settings flag on purpose: it only matches values in the map and a key is never a label, so it is self-terminating, and a flag would permanently skip anyone restoring an older database. **The editor no longer destroys what it cannot read** — an unrecognised value keeps its own option in the dropdown and survives a save, instead of initialising to empty and overwriting the stored text. Covered by 13 migration cases across both tables, including the live 91-vs-1 split, both stale sentences, free text left untouched, and running twice changing nothing; plus a test asserting every value the backend can derive is a key the rest of the stack accepts, so a display label cannot creep back into the map the way it did before. New `noStatusUpdate` label translated in all 14 locales.
- **Everything the internal slicer produced was Bambu green, whatever filament was picked (#2977, reported by @fadudba)** — A slice through Bambuddy's own slicer came out with `filament_colour = #00AE42` every time: a green plate thumbnail regardless of the profile chosen, and a **Color mismatch** in the Print dialog against the AMS slot the job had just been correctly mapped to. The reason is that a colour is not a property of a filament *preset* in either slicer — it belongs to the project, and their GUIs set it from the plate — so nothing was attached to the preset Bambuddy sent by name and the CLI fell back to its own compiled-in default, which is Bambu green. Verified against a 02.08.02.61 sidecar with the reporter's exact triplet: as sent, `['#00AE42']`; with a colour written onto the same profile, the colour asked for. Each filament row in the slice dialog now carries an editable **colour swatch**, and the colour reaches both `project_settings.config` and `slice_info.config`, which is what the thumbnail and the AMS mapping actually read. The swatch is pre-filled from the colour that slot was designed with — read from the source 3MF's own project settings — then the preset's `default_filament_colour`, then the slicer's green. It is offered on single-filament sources too, because an **STL, and equally a mesh-only 3MF exported from CAD, has no colour anywhere else to inherit**; measured, a colourless source records `color="#00AE42"` in its own slice info, so a fallback that read the *last sliced* colour rather than the *designed* one would have been circular. `default_filament_colour` is deliberately not treated as the answer on its own: the CLI never reads it — a profile carrying only that still slices green — it is consumed by the GUI when a project is created, so it is read and rewritten as `filament_colour`, which the CLI does honour. Bambu's bundled filament profiles define it nowhere at all (zero occurrences across the whole shipped tree), which is why it can only be one link in the chain. A slot the user did not touch and that has no designed colour submits an empty string rather than the swatch's displayed default, because a sent colour outranks the preset's own and pinning the placeholder would silently discard the real colour of an imported OrcaSlicer profile that carries one. Slicer Pipelines pick up the same chain without carrying a swatch of their own. The control sits beside the filament dropdown, styled like it and the same height, showing the swatch and its hex together. That placement is the third attempt and the first that reads as a control: a bare swatch in the label row looked exactly like the read-only dot multi-colour rows had carried for releases, and adding the hex beside it only made it look like a caption on the label &mdash; so on a single-filament STL, the one source with no colour to inherit and therefore the case the control exists for, nothing suggested anything was settable. The swatch and the hex are wrapped in one label bound to the input, so a click anywhere on it opens the picker rather than only a 16px dot being live. The colour is also painted on the input directly rather than relying only on the browser's native colour-swatch pseudo-element, since an unlit swatch is indistinguishable from no swatch at all. Translated in all 14 locales, wiki updated, and covered by 38 backend and 14 frontend regression tests.
- **A filament preset the slicer could not resolve was sliced as PLA at 200 °C without saying so** — Found while investigating #2977. Bambuddy names the preset to inherit and the sidecar resolves it against its bundled profile tree; when that tree does not contain the name, nothing rejects it. The CLI inherits nothing, falls back to its compiled-in defaults for every field, and returns a well-formed success — so a PETG preset whose name a sidecar image predates prints at PLA temperatures with no diagnostic anywhere. Measured against a 02.08.02.61 sidecar: an unresolvable name slices as `filament_type ["PLA"]` at `nozzle_temperature ["200"]` with `filament_ids [""]` and `filament_vendor ["(Undefined)"]`. Bambuddy now recognises that pair and logs a warning naming the slot and the preset that was picked, pointing at the sidecar image as the fix. The file is **kept rather than refused**, unlike the missing start G-code of #2838: this one prints, it is only wrong, and someone may well be slicing deliberately with a profile their sidecar predates — so the choice is theirs to make with the temperatures in front of them. Both signals are required together, which is what keeps it from firing on the two legitimate cases that look similar: a hand-written profile that simply never named a vendor still carries a real filament id, and a user's own cloud preset carries a vendor while legitimately having no bundled id.
- **An AMS slot card showed a multi-colour spool as one flat band (#2967, reported by @NeighborGeek)** — A Ziro "Colorful Mist" — yellow, cyan and pink, effect Tri Color — hovered on the printer card as a single pink rectangle, because a printer reports exactly one `tray_color` hex per tray and nothing else. Telemetry cannot describe a gradient or a surface effect and never will, so the header now paints the *spool's* own swatch whenever the bound spool carries extra colour stops or an effect, through the same builder the Inventory swatches use — the two surfaces cannot drift because there is one implementation. A plain single-colour spool keeps the flat colour it has always had, so the common case goes nowhere near the gradient path. Any stop at all counts, not just two: the colour layer ignores the base hex the moment stops exist, so a one-stop spool renders that stop rather than the slot's hex, and honouring it is what keeps the card agreeing with Inventory. The colour name and the print dialog's slot dropdown were the other two halves of the report and were already fixed on dev by #2875 and the slot-naming change that landed the day after it was filed. **Spoolman mode gained the gradient in the process**: Spoolman holds the extra stops in `multi_color_hexes` and the label renderer had been reading them for releases, but `_map_spoolman_spool` never returned them, so the identical roll registered in Spoolman rendered flat while the internally-managed one did not. Both now share one parser rather than reading the same field two ways. The asymmetry that remains is Spoolman's own and is pinned by a test rather than left to be rediscovered: it has no field for a surface effect — its only neighbouring field, `multi_color_direction`, describes how the stops are laid out, not that the roll is silk — so `effect_type` is None there instead of guessed at. The colour name moves onto the same scrim the vendor badge already uses once the background has more than one band, because a single hex cannot decide legibility across yellow, cyan and pink and the label sits dead centre where the background is likeliest to change under it; a single stop or an effect over one colour leaves a real base colour to test, and keeps the contrast rule it had. Wiki updated. 19 backend and 8 frontend regression tests.
- **A printer card said "Unknown stage (72)" where it now says "Preparing"** — New models report stage numbers before Bambuddy learns their names, and the H2C still has several. Until now those reached the card verbatim, as a number that means nothing to the person reading it, on a line that otherwise names what the printer is doing. Every stage that has turned out to be unnamed so far has been part of the run-up to printing, so an unnamed one now reads as "Preparing" — the same label stage 74 already carries, rather than a second spelling of the same idea. The substitution is display-only and deliberately not pushed down into `get_stage_name`: that function also feeds the stage-transition log line and the once-per-session warning that exists precisely to capture unnamed stages so they can be named in a later release, and there the number is the entire diagnostic value. Both paths are pinned by tests that assert they disagree for an unnamed stage and agree for a named one, so a future tidy-up cannot quietly collapse them and blind the thing that reports these. The idle sentinels (255 on A1/P1, -1 on X1) still resolve to no stage at all rather than being swept up as unnamed.
- **The internal slicer picked PETG for a PLA plate, and an A1 process for a P1S (#2982, reported by @Igiegel)** — Both traced to one line of the slicer sidecar: its bundled-profile listing read `filament_type` off the leaf preset and never reported `compatible_printers` at all. Measured against the shipped trees, the leaf read finds nothing — zero of the 1156 filament presets in OrcaSlicer 2.4.2 carry a material, and zero of the 1792 in BambuStudio 02.08.02.61 — because it lives one to four hops up the `inherits` chain (`Bambu ABS @BBL A1` → `Bambu ABS @base` → `fdm_filament_abs`). With no material on any of them the pre-pick had only colour and source to go on, so a white PLA plate drew `Bambu PETG Basic` on an A1 mini and `Bambu PC` on a P1S. The sidecar now walks the chain, which recovers the material for 1124 of the 1156 (the other 32 name a parent Bambu's own bundle does not contain, and stay listed with no material rather than being dropped). **`compatible_printers` is the second half**, and it is the only truthful account of which printer a preset belongs to: the bundle ships no process preset named after a P1S, an X1, an X1E or an H2D Pro — all ten of the P1S's are named `@BBL X1C` and name the P1S only in that list. Reading the printer out of the preset *name* therefore made a P1S look like it had no compatible process at all: all 198 were hidden behind “Show all” and the auto-pick fell through to an alphabetically-first `0.06mm Fine @BBL A1 0.2 nozzle` the CLI then refused with “the selected printer is not compatible with the process preset”. A P1S now gets the `0.20mm Standard @BBL X1C` it should always have had, and 73 filaments instead of 4. **This half needs the updated sidecar image** — an older one simply reports nothing and Bambuddy stays on the name matcher, degraded exactly as before rather than broken. Three hardenings ride along so a stale sidecar fails more gracefully: a filament preset that states a *different* material than the plate asks for is now skipped outright rather than merely scored down (a preset stating no material stays eligible — unknown is not wrong), a slot is no longer held on printer-compatibility alone once its material turns out to disagree (a preset you chose yourself is exempt — printing PETG on a plate labelled PLA is a legitimate thing to do), and a dropdown the printer filter would empty now shows everything instead, since a visible preset for the wrong printer can be changed and an empty list cannot. `filament_colour` stays null throughout, which is correct: no BBL profile carries a colour at any depth, because colour is a spool attribute rather than a profile one.
- **Every slice that didn't name its own process quietly got the slowest one the slicer ships** — Found while tracing #2982. Within a tier the preset list is alphabetical, and Bambu's naming puts the finest layer height first, so the auto-pick landed on `0.08mm Extra Fine` for an X1 Carbon and `0.06mm Fine` for an A1 mini. Correct presets, but nobody's idea of a default. Among candidates that are equally valid for the selected printer, the one nearest 0.2mm now wins — `0.20mm Standard` where it exists, the closest thing to it otherwise, ties breaking toward the coarser and therefore faster height. A preset whose name carries no readable height is still pickable when it is the only candidate, and a process the 3MF named still overrides all of this.
- **An H2D Pro classified every bundled preset as another printer's** — Also found while tracing #2982, and the same shape as the A1 mini's `A1M` rename (#1649): the bundle names H2D Pro presets `@BBL H2DP` while the printer preset, and the model registry with it, spells the model `H2D Pro`. The alias table now carries the pair. It stays deliberately narrow — `H2DP` and a plain `H2D` are still different machines and must not collapse.
- **Spoolman reset your renamed extra fields on every restart (#2983, reported by @ngreatorex)** — Bambuddy checked whether one of its four custom spool fields existed by calling `GET /field/spool/{name}`. Spoolman has never served that: its API declares only `POST` and `DELETE` at that path, so the check answered **405 Method Not Allowed** every single time and could never succeed. Each call then fell through to `POST /field/spool/{name}` — and that endpoint is an *upsert*, not a create. It answers 200 whether or not the field is already there, so a field you had renamed, retyped or given a default to in Spoolman's own UI was silently reset to Bambuddy's version of it, and an untrue `Created Spoolman extra field` was logged alongside. The reporter's log carried 60 of those lines in three days. Existence now comes from the documented `GET /field/spool` listing, matched on the field's `key` rather than its display `name` — a rename is the same field, and treating it as a missing one is what caused the overwrite. An existing field is left completely alone. **You can now rename these fields in Spoolman and the name will stick.** Registering all four also costs one request instead of four, and none at all on a client that has already looked once. If the listing can't be read at all, Bambuddy falls back to attempting the write exactly as before, so an unexpected Spoolman build is no worse off than today.
- **One ASA spool parked in the AMS added 20 minutes to every PLA print (#2886, reported by @FirstRulez)** — Preheat's chamber target was the maximum across *every loaded AMS tray*, with no reference to the job. The reporter's P2S holds PETG Pro, PLA, ASA and PETG; the ASA row of the filament map says 45°C, so a PLA-only plate was dispatched with `chamber_target=45°C`, the bed driven to 90°C to reach it, and the full 900s max-wait plus 300s soak burned before the upload even started — every time, because a P2S has no chamber heater and the chamber tops out around 33°C, so the wait can only ever end on the timeout. Their log carries fifteen of these. The intent was never in doubt: the resolution order documented one screen above reads "PLA-only print derives 0 → chamber phase auto-skips", but it was implemented as PLA-only **AMS** rather than PLA-only **print**, and only misfires on a mixed-material load. The derivation now reads the trays the item's `ams_mapping` actually names — the same array the print command puts on the wire, `[-1, -1, -1, 1]` in their case, addressing exactly the PLA slot — so the ASA two slots over contributes nothing and the stage skips outright. Multi-material prints are unaffected: the maximum is still taken, just across the trays the plate loads, so an ASA the print really does use is still the binding constraint. An item whose mapping is missing or still unresolved keeps the whole-unit scan, since that is the only signal left and narrowing to nothing would disable preheat for prints that need it. The bed hold between jobs (`queue_keep_bed_warm`) is gated on the same derivation and was holding beds at 90°C for the same wrong reason; it now reads the next item's mapping too. **The external spool is no longer invisible to this**: the scan only ever looked at `raw_data['ams']`, so an ASA print fed from the external feed derived 0 and got no preheat at all — a mapping that names 254/255 is now honoured, while an item with no mapping still derives from the AMS alone so nothing starts preheating that did not before. That covers the mappings Bambuddy builds itself, from the print dialog or the dispatcher's own matcher. It does not cover a mapping captured from a slicer through a Virtual Printer: BambuStudio writes the external spool as `-1` there, which is the same value it writes for a slot the plate does not use, so the two cannot be told apart. A wholly external one carries no usable mapping and falls back to the whole-unit scan as before; a mixed one derives from its AMS trays and the external half stays unread — which is exactly what it did before this change, since nothing ever read `vt_tray`. 29 regression tests, built from the trays and mapping in the reporter's own support bundle.
- **A spool you assigned to an AMS slot unassigned itself seconds later (#2987, reported by @frethop)** — and the slot's colour changed at the same time. It looked like Bambu Studio and Bambuddy fighting over the slot; the reporter's log shows Bambuddy losing to itself. P1S firmware 01.10.00.00 reads every **lowercase** hex letter in an AMS `tray_color` as a zero, and hides it completely: the command response echoes back the value you sent and reports `result: "success"`, so only the next AMS push reveals what was really stored. The spool-assign path sent `spool.rgba` verbatim, and that column stores lowercase — so `09ff00ff` became `09000000` on the printer and `ff5100ff` became `00510000`, while the one uppercase write in the same window round-tripped intact. That is the visible colour change. It is also what deleted the assignment: the auto-unlink sweep asks whether the slot still matches the spool it is assigned to, the mangled colour no longer did, and the assignment Bambuddy had created four seconds earlier was removed. Re-assigning could not help, because the **Configure Slot** dialog seeds its colour from whatever the printer currently reports — so it wrote the mangled colour straight back and cemented it, which is the loop in the report's steps 4 and 5. Colours are now uppercased at the single point the MQTT command is assembled rather than in each of the four routes that configure a slot, because a caller that forgets is exactly how this arrived. Nothing else about the command changes: no padding, no invented alpha, and `tray_type` / `tray_sub_brands` keep their case, where it carries meaning. **Two more things found in the same log.** A spool with a brand but no subtype was configured with the literal string `None` in its name — `"Sunlu PLA Matte None"` went on the wire, because the branded branch interpolated the subtype without checking it while the unbranded branch guarded it. And the FTP log is now readable: a `426` whose bytes Bambuddy has verified against the printer is how Bambu's FTPS normally ends a transfer, not a fault, so it is logged at INFO instead of WARNING. It fired 54 times in this one bundle, every single one followed by a completed upload, and it was burying the 26 TLS handshake failures in the same log that actually cost the reporter two prints. A `426` whose bytes do **not** verify is still an error and still fails the upload. 24 regression tests.
- **A manual K-profile calibration left a print in your archive** — Bambuddy already recognises the printer's automatic pressure-advance run, `auto_pa_line_calib_mode`, and skips archiving and notifying for it. Started by hand instead of automatically before a print, the same calibration reports under a different name: it prints a *pattern* where the automatic one prints a *line*, and carries no `auto_` prefix, so `pa_pattern_calib_mode` matched nothing. It arrives 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. It is 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` is still archived as the print it is. Wiki updated.
- **A K profile could be saved against the wrong hotend, and applied to the wrong one** — Which nozzle an AMS slot feeds, and how wide it is, was worked out independently in seven places, each reading the printer's first nozzle entry for every slot on the machine. That is correct on a single-nozzle printer and on a dual-nozzle printer with matching nozzles, and wrong the moment two sizes are fitted: the K profile for the other hotend was looked up, and with the per-model presets above the wrong preset would have been too. The resolution now lives in one place, and which array entry belongs to which hotend is no longer inferred — measured on an H2D fitted with a 0.4 on the left and a 0.6 on the right, the first entry reads the **right** hotend, so the array is indexed by extruder id. Separately, the spool form identified a chosen calibration by `cali_idx` alone, and the printer numbers its calibration table **per nozzle** — on a dual-nozzle printer the same index exists on both hotends meaning different things, so saving could persist the other hotend's K value and nozzle diameter. Each hotend is now keyed by printer, extruder and diameter throughout, which also lets a 0.4 and a 0.6 profile for the same hotend coexist — something both K tables could always store but the picker could not express. SpoolBuddy's write-tag page carried a verbatim copy of the same lookup and gets the same fix.
- **RFID auto-assign picked a K profile without checking which hotend it was calibrated on** — The first stored profile matching the printer and nozzle size won outright, with no extruder test at all. On a dual-nozzle printer a spool calibrated on both hotends therefore had a coin toss decide which pressure-advance value the slot got, on the path that runs unattended every time a Bambu spool is loaded.
- **Linking a Spoolman spool by tag configured the slot as generic filament** — That path resolved no slicer preset whatsoever and went straight to the generic material id, so a Spoolman spool with a preset set in inventory lost it the moment it was linked by tag. The same defect #1713 fixed on the assign path, in the function next door.
- **Moving an AMS to the other nozzle re-selected the K profile for the wrong one** — When a Filament Track Switch moves an AMS between inlets, the slot's K profile is re-selected for the nozzle it now feeds. It was resolved against the printer's first nozzle rather than the one the AMS had just been moved to, which on a machine with two different sizes fitted is the wrong nozzle by construction.
- **Reading a printer's calibration table could stall for 20 seconds** — H2-series firmware answers only the first one or two of a concurrent burst of calibration requests and silently drops the rest, each dropped request costing a five-second timeout before its retry: measured at 11 and 23 seconds on an H2C and an H2D for four parallel requests, against roughly one second sent in series. Sizes are now requested one at a time, while the printers themselves are read in parallel since separate machines are separate connections. An X1C answers all four at once, which is why this only ever surfaced on dual-diameter printers.
- **A batch order whose queued runs were deleted became a card that could neither be queued nor closed (#2960)** — An order queues the runs it owes by cloning an existing queue item for the same plate: that row is the only record of the printer target, AMS mapping, filament overrides and print options the user chose, and there is nothing else to copy them from. Deleting it left the order still reporting the run as outstanding, with **Queue remaining** answering "Plate 1 has no queued or finished run to copy settings from" every time, and no way out — the Cancel action was hidden unless the order had pending items to cancel, which by then it had none of. Queue a multi-plate file, change your mind, remove the rows, and the Batches tab kept a card that did nothing forever. Deleting an order's last surviving run for a plate now cancels it instead: a cancelled run does not satisfy a target, so the order still says it owes the print, and it can still produce it. That is the design the feature already documented for cancelled runs — the delete path simply never took part in it. A run that has *completed* is exempt and is still deleted outright, because rewriting a finished run as cancelled would falsify what the order actually produced, and a plate with any other surviving run is untouched, so this only ever engages on the last one. The queue and the Clear History action both say what happened rather than reporting a delete that did not occur. Independently of that, the card no longer offers actions that cannot work: the response now reports, per plate, whether anything remains to clone from, so a stranded plate explains itself instead of presenting a button whose only outcome is an error toast, and the header button offers only the runs that can actually be queued. Dispatching an order with one stranded plate now queues every other plate instead of aborting the whole order on the first one it cannot clone — an explicit single-plate request still fails loudly, and names the plate the way the Batches tab does rather than by a bare index. Cancel is offered for any active order, since closing one out is exactly what an order with nothing left pending needs. Existing stuck orders are not rewritten: they report honestly that their runs cannot be re-queued, and Cancel now closes them. Translated in all 13 locales, with backend and frontend regression tests including the delete-then-dispatch path this began as.
- **Picking a spool near the bottom of the label dialog could scroll the dialog itself out of view (#2918, found and fixed by @whitigol)** — The modal panel was `overflow-hidden` under a 90vh cap, which makes it a scroll container even though nothing was meant to scroll there. When a checkbox low in the spool list took focus the browser scrolled every scrollable ancestor to bring it into view, and the panel obliged by scrolling its own header and print buttons off the screen. It now clips instead of hiding — the same clipping, but not scrollable — so the only thing that moves is the spool list, which is the part that is supposed to.
- **A fully transparent spool printed a label with no QR code (#2918, found and fixed by @whitigol)** — The colour swatch is drawn from the spool's RGBA, and an alpha below opaque puts the PDF into a transparency state that was never turned off again. The QR code is drawn immediately after the swatch, before any other colour is set, so it inherited that alpha: a spool saved at alpha 0 produced a label whose deep-link QR was invisible, and therefore unscannable, while the rest of the label looked correct. The swatch now draws inside a saved graphics state so its transparency ends with it.