# Changelog All notable changes to Bambuddy will be documented in this file. ## [1.3.0] - 2026-10-06 ### Added - **OctoEverywhere AI failure detection (#3169, requested and contributed by @QuinnDamerell in #3428)** — AI Failure Detection gets a second provider next to self-hosted Obico: OctoEverywhere's cloud AI, with a free Gadget API key and no ML container to run. Pick it under **Settings > Failure Detection > Provider**. Camera snapshots of running prints are sent to OctoEverywhere, which returns a print quality and suggests a warning or a pause; Bambuddy then notifies, pauses the print, or pauses and turns off the printer's smart plug, as for Obico. **Confidence** (Lowest to Highest) trades early alerts against false alarms, and the **Inspection interval** runs from 5 to 30 seconds. While a failure is still detected, the notification repeats every 3 hours; a pause or power-off happens once. Usage limits and key problems show in the settings with a link to fix them. The AI Failure Detection notification now names the provider, with `{provider}` and `{print_quality}` for templates. Wiki updated. - **Custom label sheets and saved label templates (#3296, requested and contributed by @koshilki in #3385)** — The label size list in **Print spool labels** has a **Custom sheet…** entry for a sheet none of the built-in templates match: page (A4 or US Letter), label size, columns and rows, margins and gaps. A drawing of the sheet to scale follows the numbers, labels that would run off the page are drawn red with the reason, and printing waits until the layout fits. A custom sheet can be saved under a name and is then listed under **Saved templates** for everyone on the install, to edit or delete later (saving, editing and deleting need the inventory update permission). Works for built-in inventory and Spoolman alike; the built-in sheets print exactly where they did. - **Polish (#3303, requested by @lukg20)** — The interface is available in Polish (Polski), chosen in **Settings > General > Language**. Counts are written in forms that read correctly for any number, since the interface can't tell Polish's three plural forms apart. - **Each archive opens with all its prints** — An archive is one file, but everything about its prints sat on one card: its latest print's outcome, one video, one note. A click on a card (or its name in the list) now opens the archive over the list, at `/archives/` so a link to it works; Escape or a click outside it closes it: - **The file:** thumbnail (larger on hover), what it was sliced for (printer, nozzle, bed type), its project, how often it was printed (good and failed) and the slicer's estimate for the whole file, the archive's note, and **Print** (a sliced file; a source file offers **Slice** only), **Slice**, **Edit** and a menu with **3D preview**, **Download**, **All photos** and **Delete**. A **File** section below lists layer height, nozzle, bed type, layers, size, when it was archived, its MakerWorld source and tags. - **Plates:** each plate of a multi-plate file with its objects, estimate, filaments, how often it printed good and failed and how far its last print ran from the estimate, its prints' finish photos and newest timelapse video, with **Print this plate** and **Show its prints**. - **Every print of it,** newest first, as a table (date, plate, printer, outcome, time against the plate's estimate, filament, cost, filament as colour dots and its types) that can be narrowed to one plate. A click opens a print: its photos and timelapse video (a print from before prints kept their own shows the archive's photos no other print holds), start and end, energy, failure reason, who started it, its own note, its own verdict (**Set outcome** answers for that print only) and **Reprint this print**. Each plate keeps its own video. - **Calendar by print:** the calendar shows each print on the day it ended, so a file printed three times this month is there three times, and a click opens the archive at that print. With a printer picked it shows only the prints on it. Its labels are now translated. - **Print Log:** a print's name opens its archive at that print, unless the archive was deleted. The orange **N prints** badge opens the archive too. - **Filters by print:** the printer filter (in search too) finds archives with any print on that printer, not only the latest, and their print counts are of the prints on it; **Failed Prints** finds archives any print of which failed or was aborted. Aborted prints now count as failed in an archive's print counts. A link to a merged archive opens the archive it went into. Wiki updated. - **Slice dialog: sorted presets, search, stock presets listed once** - Preset groups are sorted (decimals by value), every preset dropdown has a search, and a Bambu stock preset that Bambu Cloud and the bundled Standard profiles both list appears once, as the Bambu Cloud copy. Pipelines and saved AMS slot presets that name the hidden Standard copy use the cloud one. Requested in #3386, built by @pleasantone in #3393. - **SpoolBuddy: add a scanned Bambu spool in one tap** - When an unknown Bambu Lab spool is placed on the SpoolBuddy reader, the card now shows the material, colour and weight decoded from its RFID tag, and Quick Add creates the spool with them (tray UUID and tare included) instead of a blank PLA spool. A failed re-read of the same tag keeps the data from the good read. Requested by @BenBBSLA in #2685, built by @hibikipr in #3390. - **`DB_POOL_PRE_PING` to turn off the PostgreSQL connection check (#3396, requested and contributed by @oh-forf in #3413)** — Every pooled PostgreSQL session first checks its connection, and SQLAlchemy wraps that check in an empty `BEGIN; ROLLBACK;`. On a small install that made `pg_stat_database` show around 80% rolled-back transactions and set off rollback-ratio alerts in monitoring. `DB_POOL_PRE_PING=false` skips the check and roughly halves the rollback count. The default stays on: with it off, a connection the server dropped fails the first request that uses it, so turn it off only for a database on the same host or LAN. - **Move a folder in the File Manager (#3305, requested and contributed by @rojosinalma in #3306)** — A folder could not be moved once created, only rebuilt elsewhere by hand. It can now go to the root or into another folder. - **Move folder…** in a folder's menu, in the sidebar tree and the columns view, opens the same picker as **Move files**, with **Root** on top. The folder itself, its subfolders, external folders and folders you can't write to are greyed out, and the current parent is marked. - **Drag and drop:** drag a folder onto another folder, onto a column's empty space, or onto **All Files** for the root. A drop that would change nothing or isn't allowed shows no-drop and sends nothing. Dragging files to upload works as before. - **On the server:** external folders can't be moved, nothing can be moved into one, and a destination that doesn't exist is refused, so an API call can't do what the File Manager doesn't offer. - **SpoolBuddy sends a Bambu tag's data with each scan (#2685, contributed by @hibikipr in #3389)** — A scan of a Bambu Lab tag sent only its tray UUID. The daemon now also reads block 6 (drying and nozzle temperatures) and sends the tag's data blocks (material, type, colour, weight, temperatures) along with the scan, for Bambuddy to decode. A Bambuddy that doesn't know them yet ignores them. - **Assign a spool by scanning its QR label (#1574, requested and contributed by @samedyuksel in #1606)** — Putting a spool into an AMS slot or away into storage meant finding it in a list. Its label's QR code can now be scanned with the phone or tablet camera instead. - **Assign by QR:** a new button on the **Inventory** page and on SpoolBuddy's Inventory page. Pick an AMS slot or a storage location, then scan the spool. A spool scanned into a slot leaves the slot it was in, in the same save, so a failed move leaves it where it was. A spool scanned into storage gets that location, as the Locations filter shows it. - **From the Assign Spool dialog:** **Scan a spool QR** next to the spool list assigns the scanned spool to the slot the dialog was opened for. - **Same checks as picking by hand:** an archived or missing spool says so, a spool of another material asks first (unless filament warnings are off), and an empty slot says it will be set up when the spool goes in. Both inventory modes. Spoolman labels are read as Spoolman spools. - **Camera:** the live view needs Bambuddy opened over HTTPS (e.g. behind a reverse proxy). Over plain HTTP, or where no camera can be opened, **Take photo** uses the phone's own camera. Photos are read in the browser and never uploaded. - **Also fixed on the way:** assigning a spool to an empty slot no longer asks whether it matches the tray material "". - **P2S printers show a P2S picture (#3366, requested and contributed by @andy-sorge in #3369)** — P2S printers were shown with the P1S picture. They now have their own. - **Remaining time instead of percentage in the browser tab (#3266, requested and contributed by @bgrr74 in #3283)** — With "Print progress in tab" on, the tab showed only the soonest-finishing print's percentage, and 95% of a 10-hour print is not 95% of a 15-minute one. Under **Settings → Appearance**, **Tab display** now switches the title between **Percentage** (default) and **Time remaining**, e.g. `1h 26m · Bambuddy`. Until the printer reports a remaining time, the title shows the percentage. The favicon ring keeps showing the percentage. Stored per browser, like the toggle itself. Wiki updated. - **New spools start with their product's presets and K profiles (#1326, requested by @mithkr)** — The slicer preset, the presets per printer model and the K profiles were set per spool, so every replacement for an empty spool had to be set up again by hand. They can now be saved once per product, as a profile default, and every new spool of that product starts with them. - **Saving one:** set up a spool's Printers tab, tick **Save as default for new … spools** below it and save the spool. **All colours** is preselected; **Only ** saves one for a colour calibrated on its own. A colour's default wins over the all-colours one per preset and per hotend, so the first spool of a new colour still gets the product's K profiles. Saving again replaces the default. - **Every way a spool is added gets it:** the spool form, Quick Add, bulk add, RFID auto-add and **Add to Inventory**, SpoolBuddy and a forecast reorder, in both inventory modes, including spools the AMS sync adds to Spoolman. The spool form fills its Printers tab in from the default as soon as the product is filled in, and shows it. Elsewhere only what the new spool doesn't have yet is filled in, so an RFID spool keeps the preset on its tag. - **Existing stock:** **Settings → Filament → Profile defaults** lists the defaults, deletes them, and **Apply to existing spools** fills in what active spools of a product don't have yet, never changing what they have. A default is copied onto a spool, not linked, so changing it later leaves existing spools alone. - **Also fixed on the way:** adding several Spoolman spools at once dropped their slicer preset and colour name; each copy now gets both, as a single add does. Creating a spool through the form or SpoolBuddy without picking a K profile no longer saves an empty list over what the create gave it. A user limited to some printers who saves a spool's K profiles now replaces only those printers' profiles; the other printers' profiles stay, where the save used to delete them. Wiki updated. - **Time to the next pause, and a heads-up before it (#2984, requested by @apizz)** — A pause added in the slicer, for an NFC chip, a magnet or threaded inserts, came without warning: the only way to be there for it was to look up its layer in the slicer and keep checking the layer count. - **On the printer card:** next to the ETA, in blue, the time until the next pause and its clock time, e.g. "20m to pause · 20:27". Hover it for the layer. With several pauses it counts down to each in turn. Sizes M and up; the one-line size S card has no room for it. In a speed mode other than Standard it shows the layer only, as the slicer's times are for Standard speed. - **Pause Coming Up notification:** a new event for notification channels, sent once per pause, 5 minutes before by default (at Standard speed). Set the minutes under Settings > Notifications > **Pause Heads-Up** (1-120). Off on every channel until turned on, with its own template. - **Where it comes from:** the pauses are read from the print's G-code in its archived 3MF, written there by Bambu Studio and OrcaSlicer. Prints without an archived 3MF show none. Wiki updated. - **Printer cameras from any page (#2737, requested by @apizz)** — An embedded camera overlay belonged to the Printers page: it disappeared on every other page and came back only on returning there, so a quick look at a print meant leaving the page you were on. - **On every page:** open overlays now stay open as you move around Bambuddy, and still reopen after a reload. - **Cameras button:** a camera icon at the end of the sidebar's **Printers** row (on hover when the sidebar is collapsed), in the header on narrow screens, or the **C** key, lists the printers you can see and opens one as an overlay. A camera already open is ticked and closes from the same list. Users without `camera:view` and `printers:read` don't see it, or any overlay. - **Pop out and back in:** a new button in the overlay moves the camera into its own window. The camera window has a button that moves it back as an overlay into the Bambuddy tab it came from, or another open one whose user can see that printer, and closes. With no Bambuddy tab open, the window stays and says so. - Overlays left open for a deleted printer, or one the signed-in user can't see, are dropped. Wiki updated. - **Dominican Peso (DOP) as a currency (#3388, requested by @rogerabm)** — Costs can now be shown in Dominican Pesos (RD$). Pick it under Settings > General > Cost Tracking. - **Egyptian Pound (EGP) as a currency (#3334, requested by @aammash)** — Costs can now be shown in Egyptian Pounds (E£). Pick it under Settings > General > Cost Tracking. - **Larger queue thumbnails on hover (#3313, requested by @humplemann)** — A job's thumbnail on a **Queue** card is the quickest way to tell jobs apart, but it is small. Hovering it now shows it at 256x256 next to the card, as the **History** rows already did at 192x192 (now 256x256 too). The preview opens to the left when there is no room on the right, is no longer cut off inside a batch group, and closes when you move away or scroll. Desktop only, as touch screens have no hover. Wiki updated. - **The queue says when a job printed in a colour the file didn't ask for (#3279, requested by @Lioncat55)** — With no spool of a slot's colour loaded, the queue can still send the job with another spool of the same material. Nothing showed this until the print came out. - **On the queue row:** an amber line per slot, for example "Slot 1 printed in Black (#000000), the file asks for Silver (#A4AAAC)". It stays on the job in **History**. - **When:** worked out each time the job is sent, from the spool each slot is sent with, whether the queue picked it or it was picked by hand. A similar colour doesn't count, and a colour changed on purpose with a filament override isn't reported. - **To have the queue wait** for a spool of the file's colour instead of using another one, turn on **Force color match**. - **Only for prints that started:** a send that goes back to the queue or fails before the print starts carries no note. - **A count on an app's sidebar link** — An app added to the sidebar can show a number on its link, the way the print queue shows how many jobs wait: Bambuddy Orders shows how many orders and top-ups wait in its To do. The app reports the count for its own address with an API key that has **Send notifications** (`PUT /api/v1/external-links/badge`); the link must point at that address. A count not updated for ten minutes disappears, so an app that stopped doesn't leave an old number. Wiki updated. - **USB cameras are picked from a list** — With an external camera set to **USB Camera (V4L2)**, Settings > Camera now lists the video devices the Bambuddy server can see, by name and path (`HD Pro Webcam C920 (/dev/video0)`), instead of asking for the device path. A refresh button looks again after a camera is plugged in, and a saved camera that is unplugged stays selected, marked **not found**. When no device is visible, the list says how to pass a camera to the Docker container. **Enter path manually** still takes a typed path. Looking for devices no longer holds up the server while it runs. - **The MQTT relay publishes printer offline/online and low filament** — Three new topics for Home Assistant, Node-RED and other subscribers. `printers/{serial}/offline` is sent once a printer has been disconnected for a minute, and `printers/{serial}/online` when it comes back after that, so a short blip sends neither. `printers/{serial}/filament/low` is sent once when the spool in an AMS slot drops below its low-stock threshold, at the same moment as the Low Filament notification, and works even when no notification provider has that event switched on. Wiki updated with the payloads. - **Notify! notification provider with iOS Live Activities and Lock Screen widgets (#2919, requested and contributed by @simplytoast1 in #3265)** — Notify! used to need the separate notify-bambuddy companion container. Bambuddy now talks to it directly: add a provider of type Notify! with the device ID and token. - **Alerts:** the usual events, printer filter, quiet hours and daily digest apply as for every provider, with the camera photo attached. Each printer gets its own notification thread. **Time Sensitive problem alerts** sends failures, stops, printer errors and failure detection as time-sensitive notifications. - **Live Activities:** with **Live Activities (iOS)** on, each running print shows a tile on the Lock Screen with a countdown to the end. A fault, a pause, running out of filament or a lost connection replaces the countdown with the problem. Progress style, extra values (progress, ETA, layer, temperatures), print stage, a privacy mode that hides the file name, symbol, colour and a button that opens Bambuddy can be set under **Live Activity appearance**. A dismissed tile stays dismissed for that print, and tiles survive a Bambuddy restart. - **Lock Screen widgets:** with **Lock Screen Widgets (iOS)** on, each active printer gets a widget showing its status and progress, added through iOS's Lock Screen customisation. iOS refreshes widgets on its own schedule, roughly every 15 minutes. Notify! allows ten widgets per device, so **All printers** creates at most ten. - **Browser and group recipients** (IDs starting with `WB` or `GRP`) get text alerts only, without photos, Live Activities or widgets. Mac recipients (`MC`) get alerts with photos, but no Live Activities or widgets. - **Nothing runs until it's used:** without a Notify! provider the background workers stay idle. Stop the companion container before turning on Live Activities here, or each print gets two tiles. - **Spoolman mode uses Spoolman 0.27's native tags (#3168, requested and contributed by @Niko11111 in #3252)** — Spoolman 0.27 links NFC tags to spools itself: a spool can hold several tags, and Spoolman makes sure no two spools share one. Bambuddy kept its single tag per spool in its own `extra.tag` field, so neither side saw the other's links, a Bambu spool could hold only one of its identifiers, and every SpoolBuddy scan loaded the whole inventory. - **Faster scans:** on Spoolman 0.27 or later, a scan asks Spoolman for the tag directly instead of loading every spool (17 ms instead of 88 ms on a 278-spool install). The old lookup is kept as a fallback for spools linked before. - **Tags collect themselves:** a spool found by a scan or an AMS read gets what was read added to its tags. A Bambu spool collects its tray UUID and the chip UID of each side over time. The AMS also finds a spool linked by its chip alone. - **Linking adds instead of replacing:** linking, unlinking, **Clear RFID Tag** and writing a tag with SpoolBuddy keep Spoolman's tags in step. A tag another active spool holds is refused as before. A tag still held by an archived spool, which is used up or replaced, moves to the active one. - **Copying existing tags:** when Spoolman supports native tags, **Settings → Filament** shows a **Spoolman native tags** section. **Check tags** shows what would be copied and any conflicts, and **Copy tags** copies the tags. Archived spools and AMS slot IDs are left out, and a tag another active spool holds is reported, never moved. - **Nothing lost on older versions:** `extra.tag` is still written everywhere, so readers that only know it and a downgrade to an older Spoolman lose nothing. Older Spoolman versions behave as before. - **Each spool remembers when it was last dried (#2863, requested by @USER19464727)** — The AMS humidity describes the unit, not the spool, so once a spool left the AMS there was no telling whether it had been dried. - **Filled in by AMS drying:** when an AMS drying run ends, every spool assigned to a slot of that AMS is marked with the date, the temperature (for runs Bambuddy started; the printer doesn't report it) and how long the run actually lasted. - **Only real drying counts:** the run must have lasted at least half its length. One stopped before then, from Bambuddy, on the printer or because a print took priority, leaves the earlier record alone. - **Where it shows:** a **Last dried** column in the inventory table, visible by default and sortable, and a line on the spool card. **Group similar** keeps a dried spool apart from undried copies of it. - **Set by hand:** **Last dried** in the spool form (**Color & Cost**, when editing) takes a date or **Now** for a spool dried in an external dryer, and clearing it removes the record. - **Spoolman too:** in Spoolman mode the record is kept in three extra fields on the spool. Git backups carry it as well. Wiki updated. - **Failure detection can send Obico a different Bambuddy address from External URL (#3235, requested and contributed by @chrisbennight in #3255)** — Obico's ML server fetches each snapshot from Bambuddy itself, and the only address Bambuddy could give it was **External URL**. When that is a public address behind a reverse proxy with its own login, the ML server can't get through, and detection reports a snapshot error on every check. - **The new setting:** **Settings → Failure Detection** has an optional **Bambuddy Internal URL**, used only for those snapshots. Point it at an address the ML server reaches directly, such as a Docker service name (`http://bambuddy:8000`) or a LAN address. Notifications, sign-in redirects and the other integrations keep using External URL. - **Empty uses External URL,** so nothing changes until you set it, and clearing it goes back to External URL. - **Checked on save:** the address must be a full `http://` or `https://` address. The field says so while it isn't, and the other Failure Detection settings still save in the meantime. - **The warning shown when neither address is set** now reads "No address set for the ML API to fetch snapshots from" and names both settings. - **Gotify notification provider (#2743, requested by @MrCrunshy)** — Notifications can now go to your own Gotify server. Add a provider of type Gotify with the server URL and an application token; the token travels in a header, never in the URL. - **Per-event priority:** like ntfy, each enabled event can be set to Min, Low, Default, High or Urgent, sent to Gotify as 0, 2, 5, 8 and 10. Events left at Default go out at 5, which plays a sound in the Gotify Android app. - **Photo and tap action:** with External URL set, the camera snapshot shows as a large picture in the Android app, and tapping the outcome-confirmation notification opens the outcome dialog in Bambuddy. - **The Cam Wall's tiles can be resized with the S / M / L / XL selector (#2735, requested by @cmerkle)** — The size selector on the Printers page used to grey out on the Cam Wall, and the tiles always sat up to four in a row, so a setup with two printers got two small tiles side by side. - **On the Printers page:** while the Cam Wall is showing, the selector sizes the tiles: S fits up to five per row on a wide screen, M four (as before, and still the default), L two, and XL one tile across the full width. Tiles keep their 16:9 shape. - **Remembered on its own:** the Cam Wall keeps its own size, so changing it leaves the card size alone, and the other way round. - **On `/camwall`:** the standalone page uses the size last picked on the Printers page, and a kiosk URL can set it with `size=s`, `m`, `l` or `xl`. - **Printers can have a wear cost per printing hour, added to what each print costs (#694, requested by @wreuel)** — Print costs covered filament and energy, but not the printer itself: nozzles, belts, the hotend and eventually the machine. Print farms and anyone selling prints usually add an hourly amount for that. - **Setting it:** **Edit printer** has a new optional field, **Wear cost per printing hour**, for example 0.20 for a 600 printer you expect to last 3,000 hours. Empty or 0 leaves it off, so nothing changes until you set one. - **How it's costed:** each print's duration as shown in the Print Log, times the printer's rate. Failed and cancelled prints count, because they wore the printer too. A print closed after Bambuddy was offline has no known duration and gets none. The rate applies from the next print on and never rewrites earlier ones. - **Where it shows:** a third amount with a wrench icon on the archive card (the first print, like energy), a **Wear Cost** column in the Print Log (hidden by default, sortable), a **Wear Cost** tile in Quick Stats once there is any, the **Most Expensive** record, project totals and budgets, and the measured cost of batch orders. - **Billing is unchanged:** charges stay filament only. - **The Slice dialog offers OrcaSlicer's built-in third-party filaments, and every other filament the slicer ships for Bambu printers (#3234, requested by @nvx)** — The **Standard** tier listed only part of the slicer's own presets. Two gaps, both in the slicer sidecar, which needs the updated image: - **OrcaSlicer's filament library was never read.** OrcaSlicer ships filaments that suit any printer (Elegoo, PolyLite, COEX and more, named `... @System`) in a folder next to the Bambu one. With the OrcaSlicer sidecar, the 264 of them that a Bambu printer can use are now listed. As in OrcaSlicer, each is offered on every Bambu printer except those with a filament made for that printer, so `Generic PLA @System` doesn't show on an X1 Carbon, which has `Generic PLA @BBL X1C`. - **Filaments in brand folders were missing.** Both slicers keep many Bambu-printer filaments in subfolders (`COEX PLA @BBL X1C`, `SUNLU PLA Matte @BBL X1C`, ...). The sidecar read only the top folder: OrcaSlicer 2.4.2 now lists 1997 Bambu filaments instead of 1156, Bambu Studio 02.08.02.61 1928 instead of 1792. - **The automatic pick prefers Bambu's own presets, and real filament over support.** When several presets fit a plate equally well, the Slice dialog takes the first in the list. The sidecar now lists, within the slicer's own presets and within the library: real filaments before support materials, then Bambu Lab, then Generic, then other brands. A PLA plate on an X1 Carbon still gets `Bambu PLA Basic @BBL X1C` rather than the alphabetically first `addnorth PLA`. A PA plate now gets `Generic PA` instead of `Bambu Support For PA/PET`, which states PA and used to win alphabetically. A P1P plate gets `Bambu ABS @BBL P1P` and `Bambu PLA Basic @BBL P1P` instead of a support or special-edition preset. HIPS still gets Bambu's `Generic HIPS`. - **No preset of the plate's material:** the Slice dialog's last fallback picked a preset of another material without looking at the printer, so it could choose one the slicer then refused. It now prefers one for the selected printer. - **Your own presets now slice with the settings they are based on.** A preset you saved in Bambu Studio or OrcaSlicer stores only your changes, and the sidecar fills in the rest from the preset it is based on. When that parent sat in a subfolder or had a `/` in its name, it wasn't found, and the slice ran on the slicer's defaults with only your changes applied. This hit every custom preset based on a P1P filament (Bambu Studio keeps all 67 in `filament/P1P/`), on SUNLU, Polymaker and other brand filaments, on `Bambu Support For PLA/PETG`, and on OrcaSlicer library filaments. Bambuddy's "unresolved preset" warning fired on those slices. Verified with a custom `Bambu PLA Basic @BBL P1P`: 3 settings before, all 140 now. The newly listed built-in presets resolve the same way: `Elegoo PLA @System` slices at its 210 °C, `COEX PETG @BBL X1C` at 245 °C. - **Why the reporter's Orca Cloud didn't help:** OrcaSlicer uploads only presets you created yourself. Ticking a built-in filament in OrcaSlicer only shows it locally, so it never reached Orca Cloud. The wiki says so now. - **Users who see only their own files now see only their own folders, and admins can share a folder with everyone (#3201, requested by @Minebuddy)** — Library files had an owner but folders did not, so a user with **View own library files** saw every folder anyone had made: a school with 100 student accounts showed each student every other student's folders, most of them looking empty, and offered them all as places to upload. Folders now have an owner, the user who made them. - **What a user sees:** their own folders, folders shared with everyone, folders holding one of their files, and the folders above those, so they can reach them. Users with **View all library files** still see everything. - **Sharing:** an admin (**Update all library files**) can mark a folder **Share with everyone** in its menu. A shared folder shows a people icon, everyone sees it and can add files and subfolders to it. Sharing applies to that folder, not to the folders inside it. - **Adding files:** uploads, ZIP extraction, moving files, new subfolders and MakerWorld or Manyfold imports go only into your own folders, shared ones and the top level. A ZIP whose name matches someone else's folder now gets its own folder instead of landing in theirs. - **Renaming and deleting:** with **Update own library files** you can rename and move your own folders; linking and sharing stay with **Update all**. With **Delete own library files** you can delete your own folder when everything in it is yours. An empty folder without an owner can still be deleted as before. - **Counts:** a folder's file count and last-activity time now count only the files you can see; they used to reveal other users' files (reported by @lonix). - **Existing folders:** on upgrade, a folder whose files all belong to one user becomes that user's folder, and an empty folder inside it goes with it. Every other folder (empty, used by several people, linked to a project, external, or the MakerWorld and Manyfold import folders) is shared, so it stays visible as before. Folders made while auth is off, and new external folders, are shared too, so turning auth on later hides nothing. - **The electricity price can come from a Home Assistant sensor, and energy is costed at the price of the hour it was used (#1251, requested by @Roving-Ronin)** — With a tariff that changes during the day, such as Amber in Australia or Nord Pool and Tibber in Europe, there was no way to get the right energy cost. Bambuddy kept one price and applied it after the fact: a print was costed at the price when it finished, and in **Total consumption** mode every kWh on the Statistics page, past months included, was multiplied by today's price. So the all-time energy cost moved every time the price did. Pushing the price in from Home Assistant through the API kept that one price current, but the arithmetic stayed wrong. - **Price from Home Assistant:** under **Settings → General → Cost Tracking**, **Electricity price source** can now be set to **Home Assistant sensor** and pointed at a price sensor. Bambuddy reads it when you save, every hour, and at the start and end of each print. Prices per MWh and per Wh are converted to per kWh. If Home Assistant can't be reached, the last price read is used. The push through the API still works. - **Costed hour by hour:** each hourly energy reading now records the price, and the energy used until the next reading is costed at it. A print that runs through cheap and expensive hours is costed at each, and the Statistics energy cost does the same for any date range and for all time. - **A new price applies from then on:** changing the price no longer re-costs history, so a new contract doesn't change last year's figures. Readings taken before this update keep the price that was set when you upgraded. - **The Slice dialog can list only the printers that are online and the spools loaded in them (#3172, requested by @wolfrage76)** — The person slicing is often not at the printer, and the useful filaments are the ones already loaded, because those are what a remote print can start on. The Slice dialog listed every printer profile and every filament profile, so you had to know or walk over to check what was in the AMS. Two checkboxes above the printer profile now narrow the lists. Both are off until you turn them on, and the browser remembers them. - **Only printers that are online:** the printer profiles of the models connected right now, at every nozzle size. A profile Bambuddy picked for an offline model moves to an online one. One you picked yourself stays. - **Only spools that are loaded:** each filament row lists the profiles of the spools in the online printers of the selected model, and its automatic pick is made from them. A profile you picked yourself stays. - **Pick:** a new button on each filament row opens the loaded spools, one section per printer, laid out like the AMS units with empty slots shown. Clicking a spool sets the row's profile and colour. A spool with no profile for the selected printer is greyed out. - **Matching:** a spool uses the profile set for its slot in Bambuddy, or that profile's copy for the selected printer. A spool set up from the slicer or the printer's screen uses its brand text, so Bambu "PLA Basic" finds **Bambu PLA Basic**. Profiles that state another material are never matched. - **Never empty:** hidden profiles stay behind **Show all**, in a **Not online** or **Not loaded** group. With nothing online or nothing matching, the full lists are shown. - **Access:** the checkboxes appear only for users who may see printer status, and list only the printers they have access to. - **Browse your Manyfold library in Bambuddy and import its files (#1471, requested by @hibikipr)** — [Manyfold](https://manyfold.app) is a self-hosted library for 3D models. The sidebar entry **MakerWorld** is now **Model Sources**, with a **MakerWorld** tab, which works as before, and a **Manyfold** tab. - **Connecting:** in Manyfold, open Settings → API and create an application with the scopes `public` and `read`. Then enter Manyfold's URL and the application's client ID and secret in the Manyfold tab. **Test connection** shows how many models Bambuddy can see. Bambuddy sees the models the application's owner can see in Manyfold, and never writes to Manyfold. - **Browsing:** search and page through the models with their previews, and open one to see its description, tags, licence and files. - **Importing:** **Import** downloads a 3MF, STL or STEP file into the library, into a **Manyfold** folder or one you pick. From there you slice and print it like any other file. An imported file shows **In library**, with **Show in library** and **Slice**, and importing it again doesn't download it twice. Nothing is copied until you import it. - **Several at once:** in a model, tick files or **Select all**, then **Import selected**, or use **Import all**. In the model grid, tick models (or **Select page**); the ticks stay while you page and search, and **Import their files** imports every printable file of those models that isn't in the library yet. Files import one after another, a failure doesn't stop the rest, and a summary says how many were imported, already there or failed. - **Permissions:** two new permissions, `manyfold:view` and `manyfold:import`. Existing groups get them once, matching what they have for MakerWorld. Connecting needs `settings:update`. Until Manyfold is connected, only users who can connect it see the tab. - **The secret:** the client secret is never sent back to the browser and is left out of GitHub backups. - **Old links:** links and bookmarks to `/makerworld` open the MakerWorld tab, and a sidebar you reordered or hid entries in keeps its layout. - **The chamber light can turn on whenever the camera is used (#1655, requested by @dadino)** — A printer kept dark between uses showed a black live view and sent black photos with its notifications. Settings → Camera has a new **Turn on chamber light for camera** choice: **Off** (the default), **All printers**, or **Selected printers**, picked from a list you can search by name or location. - **When it turns on:** while anyone watches the live view, including the camera wall and a streaming overlay, and for automatic pictures: notification photos, the finish photo and the snapshot that Home Assistant and other automations fetch. - **When it turns off:** 15 seconds after the last use, so a reconnecting viewer or the camera wall's refresh doesn't make it flash. Bambuddy only turns off a light it turned on: one that was already on stays on, and switching the light by hand on the printer card hands it back to you. - **Wait before snapshot** (2 seconds by default, up to 5, for all printers) gives the light, and any light Home Assistant syncs to it, time to come on before an automatic picture. It applies only when the light was off; the live view never waits. - **Not included:** the layer timelapse, the Obico check and the frames Bambuddy keeps during a print for the finish photo. They capture all through a print, and the light would flash with every frame. - **A page for managing printer locations (#2962, requested and contributed by @SVAN19 in #2995)** — Printer locations used to be a free-text field in each printer's settings, so renaming a room meant editing every printer in it, and a typo quietly made a second location. The new **Printer Locations** page, opened from the button next to the sort control on the Printers page, lists every location with its printers and their status. You can create a location before any printer is in it, give it an icon and a colour, rename it, delete it, and move printers between locations one at a time or several at once. Search, sorting and hiding empty locations help on a large farm. Locations are stored by Bambuddy, so every user and every device sees the same ones. Renaming a location also updates queued jobs that target it, so a job waiting for "any X1C in Workshop" still finds its printers after Workshop becomes Garage, and so do the next runs of a batch. Locations typed with stray spaces before are tidied up on upgrade, so "Workshop " and "Workshop" become one. Each change is saved as one step, so a failure cannot leave a location half renamed, and two locations that differ only in upper and lower case are refused. Location names are limited to 100 characters, in the printer dialog too. Changing locations needs the printer edit permission; anyone who can see printers can view the page. Wiki updated. Covered by backend and frontend tests. - **Groups can limit their members to some printers (#1727, requested by @McGagnor)** — Permissions said what a user may do, but always on every printer, so a shared fleet couldn't be split between teams, and a printer couldn't be kept free for a training session. Printer access now has its own page under **Settings → Authentication → Printer access**. There you limit a group to single printers and to **whole locations**: a location covers every printer in it, including printers added there later. Built for large fleets: search and filter by location, model and access, tick or untick everything the filters leave, see and change it **by group** or **by printer** (who reaches this printer, and which users see every printer because they're in no limited group), and save all changes together. **Who has access** in a printer card's menu opens that view. Because a location now grants access, the printer edit dialog names the groups a move affects, and only an admin can move a printer into or out of a location a limited group has. The **Printer Locations** page keeps to the same rules: renaming a location keeps its groups' access under the new name, deleting or moving printers into or out of a location a limited group has needs an admin, and a user limited to some printers sees only their own locations there. Members of a limited group see and control only its printers: the printer list and dashboard, camera streams, the queue and batches, archives, projects, statistics, print log, pipeline runs, maintenance, smart plugs, spool assignments, scheduled drying, failure detection and firmware all leave the other printers out, live updates stop arriving for them, and asking for one of them, or for an archive or job of theirs, by id answers as if it didn't exist. A user in several limited groups gets all of their printers. Groups without the switch don't limit printers, so they combine with a team group without widening it, and a user who is in no limited group keeps every printer, which means nothing changes until a group is limited. Administrators always see every printer. API keys, camera stream links, Cam Wall and overlay tokens reach only the printers of whoever created them; a key limited to some printers queues to a specific printer rather than to "Any ". A queued job only lands on a printer its owner may use: a job for "Any " picks among their printers, a job pinned to a printer that was since taken away from them waits, with that reason on the job, until access returns or it's moved, and deleting a limited user while keeping their items stages their "Any " jobs for a manual start. Editing a system group (Operators, Viewers) no longer fails on save when its permissions weren't changed, and clearing a printer's location in its edit dialog now actually clears it. - **Jobs can wait for staff review before they print (#1620, requested by @ImNanou)** — In a school or FabLab, students submit jobs and staff decide what actually prints, but anyone who could queue a job could also start it. A new permission, **Print Without Review** (`queue:start_unreviewed`), decides that. A user without it can still queue, but every job they queue waits with a **Waiting for review** badge until someone with `queue:update_all` starts it, and the print dialog tells them so. Users with `queue:update_all` are the reviewers, so their own jobs never wait. They can't start their waiting jobs themselves, nor by switching off manual start in the editor or the bulk editor, and they can't start a virtual printer's ownerless jobs either. Staff editing a waiting job that is scheduled for later doesn't start it. It covers every way a job is queued: the print dialog, the file manager's add to queue, more runs of a batch, pipelines, API keys and the webhook, where a key queues like its owner and a key whose owner needs review can't start waiting jobs. Administrators and Operators have the permission, and on upgrade every group that could queue, start or run jobs gets it once, so nothing changes until an admin takes it away from a group, and a restart doesn't hand it back. Together with the existing "own" read permissions, students then see only their own jobs and files. - **Your own logo and progress colours on the streaming overlay, and a see-through Version 2 background (#3104, requested and contributed by @adamspicedev in #3208)** — Settings → API Keys → Streaming Overlay has a new **Branding** section below the field checkboxes. - **Logo:** upload a PNG or WebP of up to 2 MiB and 4 million pixels, then tick **Custom logo**. It appears above the Bambuddy logo, which stays, in both Classic and Version 2. - Bambuddy re-saves the upload as a PNG of at most 512 × 512 that keeps its transparency, and rejects SVG and animated images. - There is one logo per installation. After you replace or remove it, refresh the browser sources in OBS. - Uploading or removing it needs `settings:update`. OBS loads it with the same Streaming Overlay token as the status feed. Camera stream and Cam Wall tokens can't read it. - The logo is included in backup and restore. - **Colours:** set **From colour** and **To colour** with the pickers or as six-digit hex codes. The progress bar, the Progress label and the percentage then run from one to the other; set both to the same colour for a solid one. In Version 2 this replaces the colours that change with the printer state, until you click **Reset colours**. - **Transparency (Version 2 only):** a **Background transparency** slider from 0 to 100% fades the dark backgrounds, so another OBS source can show through. Text, logos and the camera stay fully visible. - **URLs:** the settings are added to the overlay URL as `logo=1`, `progressFrom`, `progressTo` and `backgroundTransparency`. URLs without them look as before. - **Preview:** it now waits until you stop dragging a colour picker or the slider before reloading, so it no longer opens a new camera stream on every step. - **Create a streaming overlay token in the URL builder, or import an existing overlay URL (#3193, requested and contributed by @adamspicedev in #3207)** — Setting up an overlay used to mean creating a token in the Camera Tokens list, copying it, and pasting it into the builder; editing a scene already in OBS meant ticking every setting again by hand. Settings → API Keys → Streaming Overlay now has a **Create overlay token** button that creates a token with the Streaming Overlay scope and puts it straight into the URL. **Existing overlay URL** takes a browser-source URL from OBS and fills in its printer, layout, fields, text size, FPS, artwork, camera, transparency, logo, colours and token. It reads them the same way the overlay page does, ignoring settings it doesn't know, and builds the new URL for this installation, so a URL saved with a LAN IP comes back with the address you are using. The URL is read in the browser and never opened. A URL with more than one token, or with a user name or password in it, is refused without changing anything. A token can still be entered by hand. Tokens stay stored as hashes only, so the server cannot show one again. The builder keeps the token in memory only, shows it as `****` in the URL until you click **Show token**, and forgets it when you leave the page; **Copy** always copies the full URL. The response that returns a new token is no longer cached by the browser. Creating a token in Camera Tokens shows the server's reason again when it fails, for example when the lifetime is too long, and the Camera Tokens and builder forms now fit narrow windows. Wiki updated. Covered by backend and frontend tests. - **Portrait streaming overlay URLs for Classic and Version 2 (#3185, requested and contributed by @adamspicedev in #3209)** — Settings → API Keys → Streaming Overlay has a new **Layout** choice: **Landscape**, **Portrait** or **Both**. Each layout gets its own URL with Copy and Open buttons, the browser-source size to set in OBS, and its own preview, so **Both** shows the landscape and portrait previews side by side. A portrait URL adds `layout=portrait` and draws a fixed 1080 × 1920 composition that is scaled to fit the browser source, so text, camera and spacing look the same in the preview, in OBS and in a browser tab. Classic gets a portrait arrangement too, with larger text and a wrapping file name. A landscape URL carries no `layout` parameter and looks exactly as before, including Classic's text size in a 1280 × 720 source; Version 2 URLs without `layout` still follow the shape of the browser source. Classic now hides progress, layers, time remaining and temperatures while the printer is offline, as Version 2 already did, and shows "Printer offline" in the status line. **Behaviour change:** Classic no longer adds its own "Printer is idle" or "Printer offline" line below the status; that text now comes only from the **Status** field, so an idle printer no longer shows both "Idle" and "Printer is idle". A Classic URL whose `show=` list leaves out `status` therefore shows no idle or offline text; add `status` to it to get the line back. ### Changed - **A file printed again adds a run to its archive instead of a duplicate** — Every print of a File Manager or project file from the queue archived the file anew, so a two-plate file printed plate by plate showed two archives of the same file, marked as duplicates of each other. The print now joins the archive the file already has, as a run in its Print Log, when it is the same file, printed by the same user, in the same project, and the archive came from the same File Manager file or from none. The card shows the latest run, with that plate's picture, and the Print Log has a **Plate** column once a run printed a plate other than plate 1; older runs get their plate where the queue still knows it. This also holds while the file prints elsewhere: the same file sent to two printers at once is one archive with two runs. Prints started from the slicer or on the printer, uploads, and existing duplicates are unchanged. - **The archive export has one row per print** — An archive is a file, and each print of it a run, so the CSV and Excel export now has a row per print: the file's columns (name, file, project, tags, notes, slicer settings) repeated, then that print's own status, printer, plate, times, duration, filament, cost and outcome. New columns: **Print ID**, **Printer**, **Plate**, **Outcome** and **Duration (s)**. An archive printed before prints were logged keeps its one row. The printer, status and date filters pick prints (dates by when the print started), the project and search filters pick archives, and a user limited to some printers gets only the prints on theirs. Spreadsheets built on the old layout need their columns checked. - **Unused API routes and settings removed** — For anyone calling the API from scripts: - **Removed routes:** `POST /printers/test` (it took the access code in the URL; adding a printer tests the connection itself), the two debug routes `POST /printers/{id}/debug/simulate-print-complete` and `GET /printers/{id}/runtime-debug`, and `POST /spoolbuddy/devices/{id}/update-status`, which SpoolBuddy stopped calling when updates moved to SSH. - **Old virtual printer settings:** `GET /settings/virtual-printer` now returns only `tailscale_disabled`, `archive_name_source` and `status`, and `GET /settings` no longer lists `virtual_printer_enabled`, `virtual_printer_access_code` and `virtual_printer_mode` (`PUT /settings` ignores them). These settings stopped doing anything when each virtual printer got its own settings; use `/virtual-printers` instead. - **A grouping that ended in failures is marked failed** — A grouping whose jobs all failed, or failed and were cancelled, with nothing printed, stayed **active** for good, even through the start-up clean-up, and piled up next to real work. It is now marked **failed**, shown in red with a **Failed** filter on the Batches tab, and opens again if a job is added to it. Orders are unchanged: a failed run still leaves an order owing it. - **Deleting a custom maintenance type says it applies to every printer** — Maintenance types are shared, so deleting a custom one removes it, with its maintenance history, from every printer, including those of other teams when printer access is limited. The confirmation now says so. - **The web interface downloads about 70% less on each new version (#3175, requested by @mason9468)** — The interface's script files were sent uncompressed and with no caching instructions, so a phone fetched the whole 11 MB again and the browser asked for it on every load. - **Compressed:** scripts, styles and other text files under `/assets` now go out gzip-compressed when the browser accepts it. The main script drops from 11.0 MB to 3.0 MB. - **Cached for good:** files whose name carries a content hash are now marked as never changing, so the browser loads them from its own cache until an update brings new ones. The page itself is still checked on every load, so updates are picked up as before. - **The web interface opens faster: pages and languages load when they are needed (#3175, requested by @mason9468)** — Every page and all 15 languages were in one 11 MB script that the browser had to load and run before anything showed, which was slow on phones and in the installed app. - **Pages:** each page is now its own file, loaded the first time it is opened. The login page needs 1.2 MB instead of 11 MB, the Printers page 2.8 MB. While a page loads, a spinner shows in the content area and the sidebar stays. - **Languages:** only English is built in, as the fallback. The language in use is loaded on start, before the first screen, so a German user never sees English first. Switching language in Settings loads the new one before the switch. - **After an update:** a tab left open on the previous version would ask for files the update removed. It now reloads once and continues on the new version. If a file is still missing after that, the error screen shows instead of reloading again. While Bambuddy is unreachable, for example while it restarts for the update, the page keeps its loading spinner and reloads only once the server answers, so a SpoolBuddy kiosk is never left on the browser's connection error page. - **Installed app:** the service worker answers the content-hashed files from its own cache and drops the previous version's files when an update arrives, instead of keeping every version it has seen. - **Notification providers are listed alphabetically** — The Provider Type list in the Add Notification Provider dialog is now sorted by name, in whichever language the app is set to, instead of a fixed order that was hard to scan. - **The Streaming Overlay builder remembers your choices** — Settings → Camera → Streaming Overlay kept its choices only while the page was open, so the fields to show, printer, size, frame rate, layout, artwork, transparency, camera feed and progress colours were back at their defaults after a reload or a visit to another page. They are now remembered in the browser you used, with a "Settings saved" toast once your edits settle. The token is still never stored. A new **Reset choices** link puts everything back to the defaults and keeps the token and the uploaded logo. - **The camera settings stay short on large farms** — Settings → Camera listed every printer with its external camera switch and fields, so a farm of 120 printers got a card many screens long. The **External Cameras** list now holds only the printers that have one: **Add external camera** picks a printer from a list you can search by name or location, each row shows the camera type and address and folds its fields away, and a bin icon switches the printer back to its built-in camera. - **Camera settings have their own tab, and the settings menu is sorted alphabetically** — Camera settings were split across two tabs: external cameras under General, and camera tokens and the Streaming Overlay builder under API Keys. A new **Camera** tab now holds all three: **External Cameras**, **Camera API Tokens** and **Streaming Overlay**. Old links to the tokens or the overlay builder, including `/camera-tokens`, open the Camera tab. API Keys keeps API keys, webhooks, Connected Apps and the API browser, and is no longer shown to a user who may see none of them; before, such a user only went there for their camera tokens, so an API Keys link now opens the Camera tab for them. Settings search finds the Streaming Overlay builder. The settings menu now lists **General** first and the other tabs alphabetically in the interface language. In Connected Apps, the **Add app** button no longer runs past the edge of the card on mid-sized screens, and the Brazilian Portuguese camera button on the printer card reads "Câmera" again instead of "âmera". Wiki updated. Covered by frontend tests. ### Fixed - **Obico failure detection scores like Obico, and a resumed print isn't paused again (#3459, reported by @jasoncronje)** — Bambuddy's port of Obico's scoring left out the step that subtracts the long-term average, so whatever the camera always sees (plate texture, labels) added straight into the score, and the configured action ran as soon as that score passed High. The decision is now Obico's own: the score is the smoothed value minus the printer's long-term average, which carries over between prints on the same printer, so a background that is always in view no longer builds up. Between Low and High a slow build-up is not failing. **Notify only** fires at Obico's alert level, while **Pause print** and **Pause and cut power** wait for its pause level, which takes a clearly stronger signal and is now shown in the Status panel. A detection that appears suddenly and stays is still treated as a failure, as in Obico. Pausing a print no longer resets detection, so a print Bambuddy paused is not paused again after you resume it; the next print starts fresh. Wiki updated. - **A dropped printer connection no longer replays old commands and cancels the print (#3454, reported and diagnosed by @SaadShaya7)** — When the connection to a printer dropped and came back by itself, every command still waiting for the printer's acknowledgement was sent again. Bambu printers often don't acknowledge, so that could be a Stop or a print start from hours earlier: a print 4 hours in was cancelled seconds after the reconnect, and an old print start raised "Device is busy and cannot start new task". Commands from the previous connection are now dropped when it comes back, also when a printer that was switched off is turned on again, and the log says how many. A command sent in the seconds before a drop isn't sent again; click it once more. - **Finish photo: the plate is no longer driven to the bottom after a tall print** — With **Restore plate for finish photo** on, Bambuddy lowered the plate after the photo to the print's height + 100 mm, whatever the printer. On a 238 mm print on an H2C that is Z338, past the bottom of its travel: Bambu's own end G-code stops at the print's height + 2 mm when +100 doesn't fit, and the printer does not stop G-code sent over the network at its limits. The plate now goes back exactly where the print's end G-code left it, read from the archived file, and is only raised for the photo when the end G-code left it lower than just above the print (the H2C's doesn't, so its plate stays put). A file whose end position can't be read is not moved at all. Wiki updated. - **An archive card counts every print's video** — An archive printed more than once (each plate of a project, or a reprint) counted every print's finish photos on its card, but its video button knew only the latest print's video. The button now shows how many videos the prints have and, when there is more than one, opens the archive, where each print's video plays. A single video still plays in place. The list view and the card's menu do the same. - **"Change outcome" in an archive's print list changes it** — The button opened the outcome dialog, which for a print with a verdict only said "Already confirmed as a good part." under "How did your print come out?", with nothing to choose. It now opens as **Change outcome** with Good and Reject offered again, the current one marked, and Cancel to leave it as it was. - **Every plate of an archive keeps its finish photo** — When an archive already had a photo, the finish photo of its next print (the second plate of a project, or a reprint) was taken and saved on disk but never added to the archive, so that print showed no photo. It is now added. Photos already lost this way are put back once at startup, wherever the file is still on disk. - **One finish photo when the timelapse arrives late** — When the timelapse video arrived after the print-complete notification's 60-second wait, the archive kept both photos of the finished print: the live camera photo and the video's last frame. The frame now replaces the live photo under the same name, so the archive keeps one finish photo; with authentication off, the link in a notification already sent shows the better photo too. - **MakerWorld imports work again (#3447 reported by @waynem-bris, #3448 reported by @frederic3114)** — Since 3 November every MakerWorld import, Save and Save & Slice failed with "MakerWorld did not return a download URL": the Bambu Lab endpoint Bambuddy asked for the download link still answers "success", but with an empty link. The link now comes from MakerWorld's download endpoint for the plate (its instance), with the same Bambu Cloud sign-in; the old endpoint is still asked when that gives none. - **"Force color match" is kept on a job for a specific printer (#3445, reported by @frnzzle)** — With a specific printer picked, the print dialog showed the "Force color match" checkbox under each slot but never saved it: the job was added or saved with its trays only, and the box came back unticked. Once the tray picked for a slot no longer fit when the job came up, the scheduler matched the trays again against the colour the file was sliced with and, without that colour loaded, took any spool of the type, with "prefer lowest" the emptiest. A part sliced in white and set to black printed in green. A ticked slot is now saved, so the box stays ticked: set to a tray you picked, it asks for that tray's filament; on a tray the dialog matched by itself, for the file's own, so the job waits for it. Matching again takes only that filament or holds the job. Jobs for "Any" model were not affected. - **Configure accepts a setting id as the filament (#3444, reported by @Novastorm4332)** — A client of the REST API that configured a slot with a spool's slicer filament as `tray_info_idx` sent a Bambu setting id such as `GFSL99` to the printer, which knows only filament ids (`GFL99`): the slot came back without a colour or type and auto-unlink dropped the spool. Assigning the spool already converted it. Configure now converts it the same way, the setting id staying as the slot's setting id, and does the same for a K profile's filament id. Support and SUNLU ids, which also begin with GFS, are filament ids and stay as they are. Bambuddy's own Configure dialog was not affected. - **A project saved as .gcode.3mf is treated as a project (#3435, reported by @Mounting9612)** — Bambu Studio saves an unsliced project under whatever name it is given, and one called `Foo.gcode.3mf` went into the File Manager as a printable file. Printing it to a cost centre was refused with "Estimated cost is required for cost center prints", which says nothing about the cause; without billing it would have gone to the printer. A `.gcode.3mf` that holds no G-code is now stored as a project and offered for slicing, files already in the library are re-checked once on the update, and the queue refuses such a file with "… isn't sliced yet. Slice it first, then print the sliced file." The File Manager's Add to Queue judges a file by what it holds too, so a sliced plate exported as a plain `.3mf` can be queued from there. - **A spool swapped in mid-print shows on the printer card (#3434, reported by @0xCyberFortress)** — After a runout, a new spool loaded into the same slot and assigned in Bambuddy stayed invisible until the print ended: the printer reports the slot loaded but blank and refuses a filament setting for the slot it is printing from, so the card showed "?" and "No filament assigned". The card now shows the assigned spool on such a slot in its colour, with an amber marker, names it and offers Unassign, saying that the printer has not taken it yet. Bambuddy also stops sending that setting on every AMS change while the slot feeds the print, which the printer refused each time, and sends it once the print is over. - **Spoolman: a Bambu spool keeps its filament preset (#3433, reported by @ojimpo)** — In Spoolman mode, a spool the AMS sync created from a Bambu RFID tray was stored without the preset the tray reports, which the built-in inventory keeps. Assigned later to a slot without a reader, such as the external spool, it was written as the generic filament for its material: a Bambu PLA Basic became Generic PLA. The sync now stores the tray's preset on a new spool and fills it in on an existing Bambu spool that has none (a preset set by hand stays). And a Spoolman spool without a preset keeps the slot's current filament when it fits, as in built-in mode, before falling back to the generic one. - **A camera opened from a printer card stays on the Printers page (#2737)** — Since cameras could be opened from any page, every camera overlay was shown on every page, also one opened from a printer card or the Cam Wall, which followed you around the app. Only a camera opened with the sidebar's **Cameras** button is shown everywhere now. One opened from a card or the Cam Wall is hidden on the other pages, still open, and back on returning to Printers; opening it from the **Cameras** button too puts it on every page. Each camera remembers which it is across a reload. - **Spoolman mode no longer shows slots from the built-in inventory (#3430, reported by @sean3318)** — Since switching inventory modes keeps both modes' slot assignments, the Inventory page in Spoolman mode also read the built-in inventory's and gave them priority. Its spool numbers are its own, so a built-in assignment kept from before the switch put a printer slot on whichever Spoolman spool had the same number, and nothing in Spoolman mode could clear it: a spool taken out of the AMS kept its slot. Spoolman mode now only uses Spoolman's slot assignments: on the Inventory page, on the printer card and in SpoolBuddy (a slot's fill level came from the built-in spool too), also while Spoolman is unreachable, when the printer card offered to assign and unassign built-in spools. The built-in assignments are kept for when you switch back. - **A spool whose tag is read in a slot leaves its other slots (#3427, reported by @Spegeli)** — When an AMS read a spool's tag, Bambuddy assigned the spool to that slot and left any other slot it was assigned to alone. A spool assigned by hand to an empty slot of one printer and then loaded into another was assigned to both: the first printer's slot was configured from it once a spool went in there, and its prints were charged to a spool sitting in the other printer. A tag read now takes the spool out of its other slots, as moving it by hand does; those slots keep what the printer has set, as after unassigning. A slot whose printer reports the same tag keeps its assignment. Internal inventory and Spoolman alike. - **AMS Filament Backup pools spools the printer itself treats as backups (#1824, reported by @lightmaster)** — With Filament Backup on, a print short on its assigned spool is only held when all spools of that filament together are short. Which spools count as the same filament was decided from the spools in the inventory, by their preset as stored: a spool created from its RFID tag stores `GFA10`, one set up from the preset picker `GFSA10_02` or a user preset of its own, so two rolls of the same PLA Tough+ never pooled, and Start reported "Insufficient filament for the assigned spool" for a 194 g print with 142 g in one slot and 1000 g in the other, while the printer listed both for Auto Refill. The pool now follows what the printer reports for each tray, its filament preset and colour, which is what it switches on; the remaining grams still come from the inventory. Internal inventory and Spoolman alike, for the queue's Start, the dispatcher and the print dialog's warning. - **One plate of a multi-plate file is measured against that plate (#3375, reported by @sargonas)** — The slicer's estimate on an archive adds all plates of the file up, so printing one plate of a three-plate file showed +200 % on the card, an archive printed more than once showed no badge at all, and statistics only kept time accuracies between 50 % and 200 %. An archive now keeps each plate's estimate from its 3MF (archives made before get theirs in the background after the update), and each print is measured against the plate it printed: the card's badge compares the latest print with its plate's estimate, the archive window shows each print's duration and filament next to its plate's estimate, the statistics' time accuracy goes by each print's plate, and the print start and queue job started notifications give the plate's time (the printer's own remaining time when the plate of a multi-plate file is not known). Moving a queued job to another plate now also updates the time shortest-job-first orders it by. A print of a multi-plate file whose plate is not known gets no estimate rather than the whole file's. Wiki updated. - **Git backup: prints come back with their archives** — A Git backup held archives only, so restoring onto a rebuilt instance gave archives without their prints: statistics and the Print Log came back empty, and each print's printer, plate, outcome, cost and energy was lost. The backup now also writes each print (`archives/print_runs.json`) and which archive was merged into which (`archives/aliases.json`), and **Print archives** restores both: each print is attached to its restored archive, which then shows its latest print's outcome. An archive printed again since the backup is recognised by when it was archived and its prints' start times instead of being added a second time, and an archive merged into another, before or after the backup, is not restored as one of its own, with its spool usage going to the archive it went into. A print running when the backup was made comes back cancelled, like its archive. The one-tap confirm link, photos, timelapses and wallet links are not in the backup. Older backups restore as before. Wiki updated. - **Orca Cloud presets with quoted string lists** - Orca Cloud returns lists like compatible_printers as one INI-quoted string, so its processes were hidden for every printer, and a process made for a user printer preset was refused by the slicer. Bambuddy now reads these lists the way OrcaSlicer does, in the preset list and on the slice path. Reported in #3380, fixed by @pleasantone in #3381. - **A camera opened in the iOS home-screen web app can be left again (#3419)** — In Bambuddy added to the home screen ("Add to Home Screen" in Safari), opening a camera in window mode loaded the camera page into the app itself, full screen, with no way back short of closing the app. A web app has no second window to open, so its cameras now always open as the floating overlay: the camera button offers no window mode, and the overlay has no pop-out button. A Camera Wall tile opens the camera page in place. The camera page has a **Back** button whenever it was not opened as a window (web app, bookmark, link), which returns to the previous Bambuddy page or the start page, and its pop-in does the same there instead of trying to close itself. Bambuddy installed as an app on a desktop or Android is unchanged: a camera window opens normally there. Wiki updated. - **The connection diagnostic no longer calls a macvlan container bridge networking (#3417, reported by @ddavidebor)** — A container on a Docker macvlan network has its own address on the LAN, so printer discovery and the Virtual Printer work, yet the diagnostic warned that it was on bridge networking and should be recreated with host networking. The interface is moved into the container from the host, like a bridge network's, and the check could not tell the two apart. It now reads the interface's link type: macvlan, and ipvlan in L2 mode, pass, and the subnet check runs as on host networking. ipvlan in L3 mode gets its own warning: it passes no broadcast or multicast, so the slicer can't find the Virtual Printer and printers can only be found with the subnet scan. Where the link type can't be read, the check now says the network mode could not be determined rather than guessing bridge. The support package reads the network mode the same way. Wiki updated. - **Recalculate Costs uses the spools' current prices (#3414, reported by @BurgerKerman)** — Changing a spool's price and pressing **Recalculate Costs** changed nothing ("0 archives were updated"), whether or not the spool had its own price. Each usage record keeps what its grams cost when the print completed, and the recalculation only added those up again, so it read the old price back. The usage records of spools still in the inventory now get the spool's current price first, or the current default filament cost for a spool without one, and the archive cost is rebuilt from them. Each print in the Print Log moves by the change of its own records, or takes the archive's new cost when it is the archive's only priced print, so the statistics, project totals and order sums follow the new price as well. A reprint off a spool whose price did not change keeps its cost. Records of a deleted spool keep what they cost. In Spoolman mode, charges recorded since #3407 are repriced from the spool's current price in Spoolman. If Spoolman can't be reached they keep their price, and the Statistics page says how many. Wiki updated. - **Spoolman mode gets the same usage history and stock forecast as built-in inventory (#3407, reported by @ojimpo)** — Bambuddy charges Spoolman for every print but kept no record of it, and Spoolman itself keeps only per-spool totals. So Spoolman mode had no per-print usage at all: the stock forecast could never use its **Trend** rate, and the spool dialog showed no usage history. Each charge is now recorded like a built-in spool's: grams, print, status, and the cost when the spool has a price. So the forecast reaches **Trend** once two days of prints are recorded, and the spool dialog lists its prints. The rows are part of the GitHub backup. The fallback rate used before that was also wrong in both modes. It divided a spool's grams by the days since it was added, so shelf time counted as use. A spool used up inside that span but last used more than 90 days ago dropped out with all its grams while its days stayed in. On the reporter's SKU it showed 9.2 g/d. Each spool's grams are now spread over the time it was actually in use, and what falls inside the last 90 days is divided by those days (12.2 g/d there). The stock alerts use the same rule. Wiki updated. - **A Spoolman slot change made elsewhere now reaches open pages without a reload (#3402, reported by @Eifel-Joe)** — In Spoolman mode, an AMS slot's hover card kept showing the spool assigned before when the change came from another tab or client, here a filament scale using the API. The page only learns about slot changes that the server announces, and unassigning a slot, linking or unlinking a spool's tag, adding a spool from a slot, and writing a tag on SpoolBuddy did not announce theirs. They do now, as built-in inventory always has. Assigning a slot has announced since the move-on-scan change (#1606), but did so before taking the slot's tag off the previous spool, so a page could read that spool back and keep its fill level on the slot. It now announces after. - **A timelapse baseline the printer's card could not give no longer attaches an old video to the print (#3412, reported by @ojimpo)** — When the card could not be read at print start (here during the FTPS handshake cool-off), Bambuddy recorded the empty listing as a card holding no videos. If the card was readable again by the end, every video still on the printer counted as new: the scan attached the first one to the print, deleted it from the printer, and took the archive's finish photo from its last frame. A baseline now records whether the card was actually read, including when a listing failed for another reason. With an unread one, several unclaimed videos are left on the printer for **Scan for Timelapse**, and a single one is still attached. The card is also read again every two minutes while the print runs, because the printer writes the video only after the print ends. Wiki updated. - **SpoolBuddy reads Bambu tags reliably on the first scan (#3377, reported and contributed by @hibikipr in #3378)** — A Bambu tag's sector reads sometimes failed on the first try (authentication not answered, or a block read that came back empty), and the scan then went out without its tray UUID. Each step is now retried before giving up, a block is accepted only when two reads agree and the reader flags no error, and a reader fault during the read no longer stops SpoolBuddy from polling: the tag is still reported, matched by its UID. - **A large tag catalog no longer pushes the File Manager's file list off the page (#3411, reported by @iammenasco)** — The tag filter showed every tag in the catalog as a chip, so 160 tags wrapped to about a dozen rows and left the file list a strip at the bottom. Active tags now always show first. The others take up to two rows, with **Show all (N)** opening them in a box that scrolls on its own. From 12 tags on, **Search tags** narrows them by name. Filtering works as before. Wiki updated. - **A printer whose SD card or USB drive refuses the file no longer eats the queue (#3409, reported by @simonkreuchauff-oss)** — When a printer refused an upload with 553 or 552 (storage missing, full or unusable), or rejected the access code, the job failed. The printer was still idle, so the next job went to it and failed the same way: an H2D with an unusable USB stick put seven jobs in History within an hour. - **The job stays in the queue:** these refusals now work like a printer whose file service doesn't answer (#3210). The printer gets no jobs for five minutes, then the wait doubles with each refusal in a row, up to an hour, and the job is sent again when the wait is over. A job assigned to that printer shows what to check: "refused to store the file — check its SD card or USB drive", or its access code on the Printers page. - **"Any " jobs move on:** a job for any printer of a model now goes back to the pool when its printer refuses the file, so another printer of that model can take it. This also applies to the refusals #3210 already kept in the queue. While it waits, its row names the refusing printer and why, instead of "Busy". - **A file name no card can hold still fails the job:** a 553 for a name with a character such as `:` or `?` would be refused on every printer, so that job fails with a request to rename the file, and the printer stays in service. - **Notifications:** a **Job Waiting** notification goes out on the first refusal of a run, also when no job is left waiting on that printer, and again if the run turns into something to fix (a refused card after a dropped connection). None for a job cancelled during the upload. These refusals used to send **Job Failed**. - The upload error now says "SD card or USB drive", as an H2D's external storage is a USB drive. Wiki updated. - **Spoolman: the stock forecast no longer borrows a built-in spool's usage history (#3406, reported by @ojimpo)** — The Forecast view read Bambuddy's own usage history in every mode. That history belongs to the built-in inventory and is keyed by its spool ids, so after switching to Spoolman, a Spoolman spool whose id matched an old built-in spool took over its prints: a spool using 2.5 g a day could show 46 g a day ("Trend") and run out in days. In Spoolman mode the forecast now works from each spool's own consumption, as the stock alerts already did. - **Spoolman: an external-spool print on an X2D or H2D is charged to the holder it ran from (#3405, reported by @juandelarossa)** — A print command that carries no `ams_mapping2` (#3166) writes the external spool as -1. In Spoolman mode that charged the left holder (254) whenever the printer listed it, which the X2D always does, empty or not, and in the daily builds no spool at all, so a spool on the right holder (255) was never charged. The built-in inventory treats such a mapping as no mapping and lets the printer's `tray_now` decide, and Spoolman mode now does the same, for finished and for aborted prints. In both modes `tray_now` 255 now also counts as the right-hand holder ahead of the last tray loaded before the print, which a print from that holder never updates, so the AMS tray or left holder an earlier print used was charged instead. - **A printer that does not answer the K-profile read is no longer a printer without K-profiles (#3404, reported by @Spegeli)** — When the printer did not answer any of the three requests, the read returned an empty table, the same as a printer with no profiles for that nozzle. The K-Profiles page then said "No K-Profiles" and offered to create the first one, and the Configure AMS Slot dialog offered only the default K. Worse, a Git restore matched the backed-up profiles against that empty table and sent every one of them as a new profile, next to the originals still on the printer. The read is now an error: the API answers HTTP 504 "The printer did not answer the K-profile request", the K-Profiles page says the profiles could not be read and offers Refresh, and the slot dialog says so above the default. A restore skips that nozzle, counts its profiles failed and says the printer did not answer. The read-back after an edit no longer has to treat an empty table as a possible timeout. - **The Slice dialog picks the right material for H2C filaments with OrcaSlicer preferred (#3403, reported by @pleasantone)** — Bambu Cloud lists its filament profiles without their material, which Bambuddy takes from the bundled profile of the same name. The OrcaSlicer bundle has no H2C profiles, so every "@BBL H2C" filament stayed without one, and a PETG-CF slot could be preselected as Generic PETG, also with "Only loaded spools" on. Such a profile now takes the material of the same filament's bundled profiles for other printers ("Generic PETG-CF @BBL X1C" for "Generic PETG-CF @BBL H2C 0.4 nozzle"), when they all agree. An OrcaSlicer Cloud filament saved without a material of its own takes it from the profile it was saved from. - **SpoolBuddy (PN5180): NTAG tags with 7-byte UIDs are read through every cascade level (#3258, contributed by @Thomansky in #3346)** - They are no longer reported as 88 + three bytes. Spools linked with the short form are still found, in the built-in inventory and in Spoolman, and their link is upgraded on the next scan. - **The stock forecast names a SKU's colour once (#3400, reported by @ojimpo)** — In Spoolman mode, a filament with no colour name takes its subtype as one, so a filament named "Black" with material "PLA Basic" read "Bambu Lab PLA Basic Black Black" in the forecast list, the chart legend, the alerts, the add-to-list dialog and the shopping list. A colour that is the same word as the subtype is now left out of the name. The SKU itself, its settings and the shopping list entries are unchanged. - **The stock forecast counts used-up spools towards the consumption rate (#3399, reported by @ojimpo)** — Archived spools were left out of the forecast entirely, so once used-up spools were archived, a SKU's rate counted only what its remaining spools had used. A SKU in weekly use could show 0.2 g/day and run out in decades, and the Reorder and Stock Break alerts came late or not at all. - A spool archived in the last 90 days now counts towards its SKU's rate, in both inventory modes, on the Forecast page and for the alerts. Older ones don't, so a spool used up years ago doesn't drag the rate down. - Archived spools still aren't stock, and a SKU whose spools are all archived still isn't forecast. Wiki updated. - **Reprinting a plate of a multi-plate file prints that plate, not plate 1 (#3395, reported by @Thomansky)** — **Print** on an archive, **Print again** in the print-outcome dialog and **Re-queue** in the queue history opened the print dialog with plate 1 selected, whichever plate the job had printed. In a file with many plates, plate 1 was the only one in view, so one click on Print printed the wrong part. - The dialog now opens on the plate that was printed and scrolls it into view. If the file no longer has that plate, it falls back to the first one. - The plates that will print are named above the buttons, e.g. "Plate 10 – Lid", so the choice stays visible however long the plate list is. - **SUNLU and some P1/X1 presets put the right filament in an AMS slot (#3387, reported by @Spegeli)** — Bambuddy worked out a slot's filament id from a Bambu preset's setting id by dropping the "S" after "GF". For SUNLU that gives an id no profile has: SUNLU PLA+ (`GFSNLS03_04`) went into the slot as `GFNLS03` instead of `GFSNL03`. About thirty P1/X1-series presets carry another filament's id altogether, for example Generic TPU @BBL P1P is `GFU99`, not Generic EVA's `GFR99`. - **Configure AMS Slot** now takes the filament id from Bambu's own preset list, which carries it for every preset. - **Spool assignment, in both inventory modes, and K-profile matching** know SUNLU's ids and look up the P1/X1 exceptions by full setting id. - **Signed in to Bambu Cloud, a Bambu PLA Silk spool went into the slot as Bambu PLA Matte**: assignment read the preset without its variant suffix, and Bambu's bare `GFSA05` is PLA Matte @BBL X1. The same swapped TPU 95A HF, PA and PA-CF, Generic EVA, PETG-CF and Bambu Support For PLA. The filament now comes from the spool's full setting id. - **Orca Cloud profiles based on SUNLU** went into the slot as Generic PLA, because SUNLU and seven newer Bambu filaments (PLA Tough+, Translucent, Lite, Pure, PETG Matte, TPU 90A and 85A) were missing from Bambuddy's catalogue. They are now listed, also under the built-in presets. An Orca Cloud profile carrying one of OrcaSlicer's own filament ids is translated to Bambu's id the way OrcaSlicer does it, instead of being sent to the printer as is. - **Mail from Bambuddy is no longer quarantined for missing headers (#3376, reported by @BenjaminCs)** — Bambuddy's mail had no `Date` and no `Message-ID` header, both of which RFC 5322 requires. A strict mail server (e.g. amavis with its default header check) accepted the message and then quarantined it, so Bambuddy logged a success and nothing arrived. Elsewhere a missing `Date` counts as a spam signal. Every mail now carries both: the SMTP test, welcome, password-reset and two-factor mails, and the email notification provider. The `Message-ID` uses the sender address's domain. A sender name with accented letters or a comma also broke the `From` header so that no address was left in it; the name is now encoded on its own and the address stays readable. The log now says "Email accepted by SMTP server", since Bambuddy cannot see delivery. - **Bambu PLA Silk+ spools added by RFID get their colour name** — An RFID roll is named from the colour catalogue by its hex and the product line the AMS reports. A single-colour Silk+ roll reports "PLA Silk+", but the catalogue had the Silk+ colours (Silver, Titan Gray, Gold and the rest) filed under "PLA Silk", the line the AMS reports for the Dual/Tri Color silk, so every Silk+ roll arrived without a name, in both inventory modes. The seeded colours are now filed under "PLA Silk+". On an existing install the update moves the seeded Bambu Lab rows that are still as seeded (rows you edited or added stay as they are), and names the RFID-added Bambu spools that came in without a colour name; a name already set is never changed. - **The X2D's auxiliary fans are named the right way round (#3373, reported by @Spegeli)** — On the X2D, the printer card's **Left Auxiliary Fan** drove the fan on the right of the chamber, and **Auxiliary Fan** the one on the left. The names came from the P2S, which numbers these two fans the other way round. On an X2D the card now shows **Left Auxiliary Fan** then **Right Auxiliary Fan**, still left to right, and the confirmation after setting a speed names the same side. The P2S is unchanged. The API keeps its field and fan names (`left_aux_fan_speed`, `aux`, `aux2`): on an X2D, `aux2` and `left_aux_fan_speed` are the right fan. Wiki updated. - **The full-page G-code preview draws on wide screens (#3371, reported by @Underscan007)** — On a window 1024 px wide or more, with classic (non-overlay) scrollbars, **3D Preview** stayed black while the page scrollbar flickered on and off. The canvas was briefly wider than its pane after each resize and pushed the page into scrolling, which narrowed the pane again, without end. The preview is now clipped to its pane, so it settles at once and draws. - **A print that finishes while Bambuddy restarts is archived as completed (#3310, reported by @Thomansky)** — When a print ended while Bambuddy was down (a container update, a host reboot), the printer's FINISH arrived as the first status of a new connection, so it never counted as the print ending. The reconnect then closed the archive as aborted with "No status update", logged it with no print time, cancelled its queue job and sent "print stopped". Now, when the printer still reports the archive's own job (the same job ID) as finished, the archive is closed as completed, with the library print count, batch run, finish notification and smart-plug auto-off that go with it. Its end time is the start plus the slicer's estimate for the plate that printed, never later than the reconnect, since the printer doesn't say when it finished. That time counts in the print-time totals but not in time accuracy, on the archive card or in Statistics. A failed job is closed as failed, with the printer's error as the reason when it has one. When the printer is idle, shows another job or reports no job ID, the archive is closed as aborted, as before. - **Home Assistant's connection test names a certificate problem (#3300)** — After Home Assistant moved to https, **Test Connection** said "Could not connect to Home Assistant" when Home Assistant was up and answering, because a certificate Bambuddy does not trust fails like an address nothing answers on. It now says the certificate is not trusted, why (self-signed or from your own authority, issued for a hostname while the URL uses the IP address, or expired) and what to do, and says when https was used on a port that serves plain http. The Smart Plugs page of the wiki explains how to add your own certificate authority, also on a native install. - **A bug report is no longer sent when debug logging stops (#3363, reported by @choumarin)** — "Stop & Submit" turned debug logging off and filed the report as a public GitHub issue in the same click, and the 5-minute auto-stop did the same with nobody watching, so a report meant only to capture a log was sent before it was ready. The button is now "Stop Logging": it collects the logs and ends on a review that says a public issue will be created and how many log lines were collected, with Submit and Discard. Closing the panel keeps the review, and a failed send goes back to it with the logs kept. - **A camera turned sideways fits the camera window (#3362, reported by @BurgerKerman)** — With a camera's rotation set to 90 or 270 degrees, the camera window that floats over the page laid the picture out as if it were still landscape and then turned it, so it was cut off in a wide window and surrounded by black bars in a tall one. It now fits whatever size the window is resized to, as the camera page opened in its own browser window already did. The same happened to Cam Wall tiles, where a portrait camera was cut off at the top and bottom, and to the classic stream overlay without a fixed layout; both are fixed too. The camera page in its own window sized the picture to the screen rather than the space below its header, which cut a little off the top and bottom in a wide window; it fits the space now. - **A SpoolBuddy kiosk no longer gets stuck on the login page during a database problem (#3360, reported by @sargonas)** — A kiosk signs in with its API key from the launch URL. When it loaded while Bambuddy's database was unavailable (out of connections, or a read-only replica after a failover), it gave up after three quick tries and showed the login page, which a touch-only screen can't use, until the next reboot. Pages now wait on a "Can't reach Bambuddy. Retrying…" screen and keep trying in the background, then show the page as soon as Bambuddy answers. A key or session Bambuddy reports as invalid, expired or belonging to a deactivated account still goes to the login page. This also covers browser tabs with a saved sign-in, which used to show the login page, or the app as if sign-in were off, when they loaded during an outage. Writing an API key's last-used time no longer refuses a key that has already been checked: if the write fails, the key is accepted and the failure logged. That time is now written at most once a minute instead of on every request, which takes one write and commit off almost every request made with a key. A write that fails is not tried again for a minute either. Camera tokens for the Cam Wall, stream overlays and camera streams get the same treatment: a failed last-used write no longer refuses a valid token. - **Slicing for the Cool Plate SuperTack works (#3359, reported by @Akaviiii)** — Picking **Cool Plate SuperTack** in the Slice dialog sent a plate name neither Bambu Studio nor OrcaSlicer knows. The slicer then sliced for the Cool Plate and failed with "Filaments are not compatible with the plate type". **Smooth PEI Plate** had the same problem: the slicers call that plate High Temp Plate, so the dialog now offers it once, as **Smooth PEI Plate / High Temp Plate**. Slicer pipelines saved with either option slice on the right plate without being saved again. A plate name no slicer knows is now refused when slicing through the API or saving a pipeline, instead of being sliced as Cool Plate, and so is OrcaSlicer's Textured Cool Plate when Bambu Studio does the slicing. A pipeline holding a plate the dialog doesn't list shows that plate when it is applied, instead of showing Auto, and a run of a pipeline with a plate no slicer knows fails with a message saying so. Files sliced for OrcaSlicer's Textured Cool Plate now also record their bed temperature. - **The MQTT Debug log shows what the printer sent** — Each logged message was the same object Bambuddy then processed, and processing writes Bambuddy's own state into it. An incoming push therefore showed Bambuddy's merged AMS list instead of the printer's AMS block, without fields such as `ams_exist_bits` and still listing an AMS that had been unplugged, and later messages could gain an AMS list they never carried. The log now keeps every incoming message exactly as it arrived. - **Printers without an AMS print from the spool holder (#3356, reported by @dogod3814t)** — A queue print to a printer with no AMS attached, such as a P1S on its spool holder alone, failed with "Failed to get AMS mapping table" when nothing was set on the spool holder. With nothing to match, the job went out without a filament mapping and asking the printer to use its AMS, which it refuses. Bambuddy now tells a printer that reports no AMS to print from the spool holder, as the slicer does. Dual-nozzle printers are unchanged. An AMS unplugged while Bambuddy runs also disappears from the printer card and from slicers connected through a virtual printer now, instead of staying until a restart, so this works straight after removing one. As with a spool taken out of a slot, spools assigned to an AMS that stays unplugged for more than two minutes are unassigned; RFID spools are assigned again when the AMS is back, other spools need assigning again. - **A personal cost center's budget can be changed (#3358, reported by @airforceixi)** — Saving the **Edit cost center** dialog on a user's personal cost center failed with "Private cost centers cannot be deactivated or renamed; set their budget to 0 to prevent printing", even when only the budget was changed, so a budget (or 0, to stop that user printing) could not be set. The dialog always sent the name as well, which a personal cost center refuses, and the budget was never saved. It now saves only the budget for a personal cost center, and its name field is greyed out, as the name is the user's. - **Slicing a painted multi-colour model keeps its colours (#3355, reported by @cartman75)** — On a model coloured with the paint tool, slicing in Bambuddy could turn several filaments into one colour, for example black, white, green and green all coming out green. Before slicing, Bambuddy works out which filaments the plate doesn't use and swaps them for one it does, so the slicer's material checks pass. It misread the painted areas, so painted filaments counted as unused and got another slot's profile and colour, and the print dialog then mapped them to that slot's spool. Painted areas are now read correctly, including small ones. A filament that really is unused keeps its own colour when it is swapped. - **An AMS Lite shows its serial number and firmware version (#3348, reported by @Spegeli)** — The printer reports both in its version answer, where the AMS Lite calls itself `ams_f1`. Bambuddy only knew the names of the other AMS models, so it skipped the AMS Lite's entry. The AMS popup showed "—" for both, the status API returned them empty, and the log warned on every connection that they were missing. They now come through on the A1, A1 Mini and A2L, including the A2L's AMS Lite, which the printer numbers differently. A name given to an AMS Lite before this, which was kept by its position because it had no serial number, still shows on the printer card and in the inventory's location column. Saving it again moves it to the serial number, so it follows the unit, and **Clear** now removes it; before, the old name came back after clearing. - **The queue's "View in File Manager" icon opens the file (#3336, reported by @Magical-Penguin)** — On a job added from the File Manager, the link icon next to the name went to `/library`, which is no page, so the screen stayed empty. It now opens the File Manager in the file's folder, with the file marked and scrolled into view, in the grid, list and column views. The folder is looked up when the link is opened, so a file moved since it was queued is still found. A deleted file, or one you can't see, shows a warning. The archive icon on the same cards opened the Archives page without pointing at the archive; it now marks the archive too, switches from the log or calendar view to the grid, and warns when the filters hide it. An archive tile on a project's page did the same and now opens its archive marked as well. Both look in a freshly loaded list, so an archive created since the page was last open is found. Wiki updated. - **An H2D Pro reports its chamber and can heat it, and so can an X1E (#3335, reported by @Spegeli)** — A printer stored as "H2D Pro", which is what discovery and the add-printer list store, was checked against the model lists as "H2D PRO", with the space kept, while the lists spell it "H2DPRO". So an H2D Pro got no chamber temperature on the printer card and in the API (Home Assistant included), setting its chamber temperature was refused, and queued prints got no chamber preheat or soak and never switched the airduct to heating, or back to cooling for a PLA print after ABS; the printer card did not offer its airduct toggle either. Every model check now compares the name without spaces, and the card lists the H2D Pro. Separately, the X1E was listed as having only a chamber sensor although it has a chamber heater (up to 60 °C): setting its chamber temperature was refused and the queue never preheated its chamber. It now does both; the X1E has no airduct flap, so none is switched. - **A print started over LAN is linked to its archive, and its print time is no longer short (#3327, reported by @Andrewdlec)** — A print sent from Bambu Studio over LAN, or started from Handy or the printer's screen, reports no job id, and printer status found a running print's archive only by that id, so `current_archive_id` stayed empty for the whole print and the printer card could not name the plate of a multi-plate file. Status now also finds the running print's archive as soon as it exists, also after a Bambuddy restart mid-print, and never names an archive that was deleted. The printer card's cover image and a reloaded skip-objects list of such a print are now read from the archived 3MF instead of being fetched from the busy printer again over FTPS (skip-objects only while Bambuddy knows which archive is the print's, never by name). Separately, an archive's `started_at` was the moment it was written, after the 3MF download, which can take a minute at the start of a print; the print time taken from it at completion was short by that much. It is now the moment the print was seen to start, also for an archive written without a 3MF. - **A spool replaced during a print is charged only for its part (#3325, reported by @HarryCoff2)** — When a spool ran out and a new one was loaded in the same slot (an external spool, or an AMS slot without a backup) and assigned in Bambuddy while the print ran, the whole print was charged to the new spool. Bambuddy now notes the layer of the new assignment and, when the print ends, splits the filament between the two spools by what the G-code feeds before and after it, for completed and failed prints, with the built-in inventory and with Spoolman, also after a restart mid-print. A slot that had no spool when the print started is still charged to the spool assigned to it, for the whole print. - **A write is saved before its response is sent (#3320, reported by @MorganMLGman)** — Many API routes left the final save to the end of the request, and that ran only after the response had gone out. A client reading straight after a successful write could miss it (a new project came back as 404, an edit still showed the old value), and a save that failed at that point was undone behind a success the client already had. Every route now saves before it answers, and a failed save is reported as an error. - **A failed or cancelled print's card says its cost is for one full print (#3318, reported by @ojimpo)** — After a print failed or was cancelled, the archive card showed the cost and grams of the whole file with no hint, as if that were what the run cost. The archive keeps one full print's figures on purpose (reprints are estimated and billed from them); the cost and grams now carry a tooltip saying so, pointing to the archive's Print Log for what the run used. With Spoolman, that run's cost is priced from the spools it was charged to. - **A print that fails or is cancelled early is charged only for the filament it used (#3317, reported by @ojimpo)** — Two errors added up to a failed print being charged to Spoolman as if it had finished, sometimes for more than the finished print would have used. Even with both fixed, a print cancelled near the end could still cost more than the same print finished. - **The previous print's layer:** right after a print starts, the printer can send one update that still carries the layer the previous print ended on (seen on an H2S: layer 357 of 357 at 0 %). Bambuddy took it as this print's last layer, so a print that failed before its first layer was charged up to layer 357. That value is now ignored until the new print reports a layer of its own. It also no longer counts as a layer change, so it no longer adds a layer-timelapse frame at print start. - **Retractions:** partial usage is worked out from the extrusion in the file's G-code, which counted each un-retract but never the retract before it. Every retraction added to the total, putting partial usage up to 1.7x above the slicer's weight. Retractions are now subtracted. The split between spools after an AMS switch to a backup spool mid-print uses the same figures. - **Cancelled near the end:** the G-code feeds more than the slicer counts (the start sequence, and the purge when the AMS changes filament) and feeds it before the layers it serves. A finished print is charged the slicer's weight, so a two-colour X1C print cancelled at layer 33 of 47 cost 2.55 g against 2.33 g for the whole print. A cancelled print is now charged each filament's slicer weight, times how far the G-code had fed that filament when the print stopped: 2.19 g in the same case. The purges still count when they happened, and a cancelled print never costs more than the finished one. After an AMS switch to a backup spool, that weight is shared between both spools by what the G-code fed from each, and Spoolman no longer charges a cancelled print for a filament the slicer weighed at 0 g, as a finished print never did. Both Spoolman and the built-in inventory. Thanks to @Andrewdlec for the files. - **Writing a SpoolBuddy tag takes it off the spool that held it before (#3312, reported by @sgiffhorn)** — With Spoolman, writing a tag that another spool already carried was meant to clear it from that spool first, but Spoolman refused the empty value Bambuddy sent. Both spools kept the tag, and the next scan could show the old spool. The tag is now cleared properly, from every other spool that carries it, including when the spool being written already held it as a Spoolman 0.27 tag. - **A large 3MF that times out at print start is not downloaded a second time (#3308, reported by @elizabethburg)** — On a slow link, such as an A1 in its start-up routine, a 3MF too large to finish in time was downloaded twice: once by name, and again when the folder listing found the same file. Each attempt took up to 5 minutes, holding the printer's only file connection with the cover images queued behind it. It is now tried once. The archive starts without the 3MF and the recovery retry fills it in, with up to 15 minutes for a large file. - **"Print again" on the printer's screen is archived with its 3MF (#3307, reported by @elizabethburg)** — A print that archived in full the first time got an empty archive (no 3MF, thumbnail or filament) when started again with **Print again** on the printer. On a reprint the printer doesn't name the file on the card: it reports its own internal copy under a generic name, plus the model's title from inside the 3MF. When that title differs from the file name, as with a project saved under another name, Bambuddy couldn't find the file. - **Now:** when the file can't be found, Bambuddy looks for an earlier archive of the same job on the same printer and archives the new run with that 3MF. The earlier run must have been sent from the slicer or started on the printer, with the same title and plate, and the same layer count when the printer has already reported one. The log names the archive the 3MF came from. - **Unchanged:** a run with no such earlier archive still gets the name-and-timing archive. When the card lists a file for the print and only its download failed, the retry fetches that file instead. - **Check Belt Tension on a P1 printer links to Bambu Lab's belt tensioning guide (#3302)** — The maintenance task's help link on a P1P or P1S opened Bambu Lab's general P1 maintenance recommendations instead of a belt guide. It now opens the "X1/P1 Series Belt tensioning procedure" page, which covers both series. - **Searching "#123" in Assign Spool finds that spool whatever the slot holds (#3301)** — Searching for a spool's number with a `#` in front, as in `#123`, only looked among the spools matching the slot's material. A spool of another material came up empty unless **Show all spools** was ticked, as if it had been deleted. - **Now:** a `#` search shows exactly that spool, whatever its material. Assigning a spool of another material still asks first, as before. Any other search still lists only spools matching the slot. - **When the spool still can't be offered**, the list says why: it is already assigned to another slot, it is archived, or there is no spool with that number. - **The File Manager's buttons fit on a phone (#3295, reported by @apizz)** — The buttons at the top of the File Manager (view mode, Generate Thumbnails, Link External, New Folder, Tags, Purge old, Trash, Upload) sat in one row that couldn't wrap. On a phone they ran far past the right edge and pushed the whole page sideways. They now wrap onto more lines when the screen is too narrow, on a phone or a narrow desktop window. - **The print dialog's slot picker can be read on a phone held upright (#3294, reported by @apizz)** — In the Print and Schedule dialogs, the picker that chooses a printer slot for each filament was squeezed to a color dot and an arrow on a portrait phone. On printers with a nozzle rack (H2C) it had about 55 pixels. Its open list was only as wide as the picker, so the slot names couldn't be read until the phone was turned sideways. - **Now:** on screens narrower than 640 pixels the picker gets a line of its own under the filament the file asks for. The open list is as wide as the slot names need, up to the width of the screen, and moves left instead of running off the right edge. The same applies to the filament override picker used when printing to any printer of a model, and to the custom slot mapping shown for each printer when printing to several. On wider screens the rows are laid out as before, and a long slot name in the open list is shown in full instead of cut off. - **Stitching a layer timelapse no longer needs gigabytes of memory (#3292, reported by @fma965)** — ffmpeg's x264 encoder picks its number of threads from the host's cores, not the container's CPU limit, so stitching 300 frames of 1080p peaked at 1.2 GiB on a 30-core host and 120 frames of 4K at 4.6 GiB. ffmpeg runs inside Bambuddy's container and counts against its memory limit, so a pod with a 2 GiB limit was killed the moment a long print finished, and its timelapse was lost. - **Now:** the stitch uses two encoder threads and a shorter lookahead, with the same quality settings. Frames larger than 1080p are scaled down to it, keeping their shape. Measured: 1080p 1.2 GiB → 300 MiB, 4K 4.6 GiB → 270 MiB. On a host with many cores the stitch takes longer. Saving an edit in the timelapse editor encodes with the same limits. - **No ffmpeg left behind:** the camera snapshots, the printer camera capture, the last frame taken from a timelapse and the stitch now stop their ffmpeg however they end. Before, only running out of time did, and an error or a cancelled capture left ffmpeg running and holding memory. A stitch that stops part-way no longer leaves a partial video behind. - **The timelapse player fits the screen with a portrait video (#3281, reported by @BurgerKerman)** — A timelapse from a camera turned to portrait with the External Camera **Rotation** setting came out taller than a landscape screen. The player grew with it, so the bottom of the video and the play, seek and speed controls were off screen and could not be reached. - **Now:** the player is never taller than the window, and the video is scaled down to leave room for the controls, with black bars at the sides. A landscape timelapse is only scaled down when the window is too short for it. In the timelapse editor the video takes at most a little over half the window's height. - **Translated:** the player's Edit, Download and Speed labels and the skip buttons' tooltips were always in English. - **A color set on the printer's touchscreen is named after the Bambu color it is (#3279, reported by @Lioncat55)** — A slot without an RFID tag reports a hex a step or two off Bambu Studio's, for example `#5A657B` for **Blue Grey** (`#5B6579`). No catalog entry matched it exactly, so the slot was called by a general name such as "Blue", while the printer's screen showed a gray. - **Now:** when no catalog entry has exactly that hex, the color is named after the closest **Bambu Lab** color in the Color Catalog, as long as it is close enough to be the same color. - **Within the slot's own range:** the range comes from the slot's material or, without RFID, from the filament preset picked on the printer's screen. A PETG Translucent slot is named from PETG Translucent colors, and a range Bambu makes never borrows a nearby color's name from another range. Only a filament outside Bambu's ranges is compared with all of them. - **Where:** the AMS slot details on the printer cards, the print dialog and the queue. A hex with no Bambu color that close keeps its general name. Spool names in Inventory are unchanged. - **A job queued for "Any" model no longer keeps returning to a printer that can't map its filament** — When choosing a printer, the queue only checked that each filament type, and each forced colour, was loaded somewhere on it. On a dual-nozzle printer (H2D, H2C, X2D) without a filament track switch, a spool only feeds the nozzle it belongs to. A printer with the filament on the other nozzle was still chosen, and the job was held there and handed back. **Start** then let it go without offering **Print Anyway**, and the next pass chose the same printer again. - **Now passed over:** a printer is only chosen when the job's filaments can be matched to its spools the way they are when the job is sent. Another printer of the model that has them gets the job. - **Also on single-nozzle printers:** the queue gives each slot its own spool, so two slots of the same material need two spools of it. A printer with only one spool of it was chosen and looped the same way. - **When none has them,** the job waits unassigned, with the filament it needs in its reason, and goes out once a spool is loaded. **Print Anyway** is unchanged. - **Force color match is kept for a job queued to a specific printer (#3280, reported by @Lioncat55)** — A job queued for one printer, for example through a virtual printer with **Force color match** on, printed on the first spool of the same material when none had the requested colour. A job queued for "Any" model was held in the same situation. - **Now held:** the job waits with "Needs PLA #A4AAAC" on the queue row until a spool of that material, in exactly that colour, is loaded. Then **Start** sends it. A spool of a similar colour no longer counts, and when the file asks for a particular variant (Basic, Matte, Silk), a spool of another variant doesn't either. A spool that reports no variant still counts. - **One spool per slot:** two slots asking for the same filament need two spools of it, for a job queued for one printer and for "Any" model alike. Before, one spool counted for both when choosing a printer, and the second slot was then left without one. - **A slot chosen earlier is checked again:** a slot picked before the spool was swapped is checked against the colour again when the job is sent. - **Printers without an AMS** hold the job too, instead of failing it, so the right spool can be put on the external holder. - **Print Anyway** still sends the job as it is. - **Escape closes the Build Plate Check result as soon as it appears** — Pressed in the moment the result opened, Escape could be ignored, so the window stayed open and a chamber light the check had turned on stayed on until it was closed another way. - **The Printers page sent hundreds of requests on a print farm, and every page asked for each printer's status** — Each printer card fetched its own status, slot presets, AMS labels, power plug, action buttons, sensor readings, firmware check and queue, one request per printer. With 59 printers that was about 550 requests when the page opened, which the server worked through one behind the other, so cards took 5 to 20 seconds to fill. The sidebar, the camera wall and other pages also fetched every printer's status one by one. - **Now batched:** requests for the same kind of data that arrive within a few milliseconds go out as one request, so the Printers page sends about one request per kind of data however many printers there are. The cards' own refresh timers are unchanged, including the 30-second status poll, and they too are combined into one request each round. - **Same answers:** each combined request returns exactly what the per-printer one did, with the same permissions and printer access. Refreshing a single printer after an action, or a page that shows one printer, still sends the per-printer request. - **Archives page:** the folders shown on each archive card are fetched the same way, instead of one request per card that each loaded the whole folder list. - **Opening the Queue page could stall Bambuddy for over a minute on a print farm** — The page fetched the whole queue, finished runs included, every five seconds and sorted out the history in the browser. With a few thousand finished runs that was several megabytes per refresh, and building the answer held up every other request: printer cards, the camera, everything, until a proxy in front of Bambuddy gave up with a timeout. - **History is paged on the server:** the History tab loads 50 runs at a time and **Show more** fetches the next 50. Sorting by date, name or printer and the printer, status and location filters are applied by the server. A batch row still counts all of its runs. - **The queue itself** refreshes only pending, printing and skipped jobs every five seconds. - **Clear History is one request** instead of one per finished run, with the same rule as before: a run a batch order still needs is kept as cancelled (#2960). It now clears exactly what the History tab shows, so the location filter counts too, as the confirmation's number already did. - **Saving in the timelapse editor could fail on long videos, and ignored the music volume** — Save re-encoded the video while the browser waited, which for a long timelapse took minutes. A browser or reverse proxy that gave up showed an error while FFmpeg went on running on the server. - **Runs in the background:** Save now starts the work on the server and returns at once. The button shows how far it has got, **Cancel** stops FFmpeg and keeps the original, and an editor closed and opened again shows the progress. While one edit of a timelapse runs, Save is unavailable for it. If the timelapse is removed or replaced while the edit runs, the edit is not saved over it. - **Music volume:** the volume slider only changed the preview; the saved video always had the music at full volume. It is now mixed in at the volume set, and muted means silent. - On a broken file, reading the video's details now gives up after a minute and each timeline frame after 30 seconds, instead of waiting forever. - **Firmware could be uploaded to a printer in the middle of a print** — The firmware update dialog offered **Upload Firmware** whatever the printer was doing, and the upload wrote to the SD card the print runs from. It is now only offered while the printer is idle (a finished or failed print counts), and the server refuses it otherwise. If a print starts while the firmware is still downloading, nothing is written to the card, and the queue holds its next print for a printer until its firmware upload is over. - **Reset Slot cleared an AMS slot on a single click, even mid-print** — It now asks first, and is unavailable while a print is running, paused or starting, since the print may be feeding from that slot. - **Some destructive buttons acted without asking** — **Clear all** on the shopping list, **Clear** in the Git backup history and removing a linked SSO account now ask for confirmation first. An account created through SSO, which has no password, can no longer remove its last SSO link, which was its only way to sign in. - **LDAP and SMTP "Test" checked the saved settings, not the form** — Changing a field and clicking **Test Connection** or **Test Email** tested the old values, so a test could pass on settings that weren't the ones about to be saved. Both now test what the form holds, before saving. LDAP's test also works while LDAP is still disabled, and in that case now uses a saved CA certificate path too. An empty password keeps the saved one, as when saving; with a changed server it has to be entered again. - **A user's email couldn't be removed** — Emptying the email field when editing a user saved without error but kept the old address. It is now removed. The edit is refused while advanced authentication is on, or while the user signs in with email codes (email two-factor authentication), since both need the address. - **Deleting a local backup failed silently** — When the server refused or failed the delete, no message was shown and the file stayed in the list. The error is now shown. - **Purging archives could delete by an age that wasn't previewed** — The "Purge old archives" dialog previews what it will delete a moment after you stop typing, but the delete button took the age currently in the field. Changing the age and clicking straight away deleted archives for the new age while the button still showed the old preview's count. The button now waits until the preview matches the field, and the purge uses the previewed age. - **Editing a transaction to "No cost center" kept the old cost center** — Finance showed "Transaction updated", but the entry stayed booked to its previous cost center. - **After deleting a printer, spools still showed as loaded in it** — The spool assignment list wasn't refreshed, so the inventory kept showing spools in the deleted printer's slots for up to 30 seconds. - **API keys of Operators were refused on queue, archive and library routes** — A key whose owner may act only on their own rows (the built-in Operators group) got `403 API key owner does not have 'queue:update_all' permission` when it listed, edited, cancelled or deleted even its owner's own queue jobs, and the same on archives and library files. Such a key now acts on its owner's rows and not on anyone else's. Keys of owners with the `_all` permissions, and keys without an owner, reach every row as before. - **Statistics: Today, This Week and the other ranges used UTC days** — Near midnight they showed the wrong day's prints for anyone outside UTC: in Berlin at 01:30, "Today" showed the previous day. A day now runs from midnight to midnight in your browser's time zone, for every date filter on the Statistics page, and the date pickers allow today. API callers can name the zone with the new `tz` parameter on the date-filtered archive and statistics endpoints; without it, a date is a day in Bambuddy's own `TZ` (UTC days before). - **A print that failed on its first run left the archive costing only the part it printed** — With the built-in inventory, the archive took the failed run's partial cost, so a reprint was estimated, reserved and billed from that fraction. The archive now keeps the cost of one full print (as in Spoolman mode); the failed run's own cost is still recorded in the Print Log. - **Spoolman: a very short print could end with its estimated cost instead of the real one** — The cost estimate made at print start could still be waiting on Spoolman when the print ended, and then wrote over the real cost. Completion now stops it first. - **After a 2FA or SSO login, LDAP and OIDC users were offered "Change Password"** — The login answered as if they were local users, until the page was reloaded. - **The same printer fault could be listed twice** — A fault the printer reported both as a print error and in its HMS list was not recognised as the same one. - **H2D: a refused manual filament load could show the wrong active tray later** — Its target slot was remembered until something cleared it, so a print loading the same slot number on another AMS much later was shown as that slot. It is now forgotten after two minutes. - **K-profiles: a printer refusing the re-applied profile after an RFID read was not in the logs** — Only the "applied" line was, which made wrong pressure-advance prints hard to diagnose. - **K-profiles: opening several pages at once could fetch the cloud filament names many times** — Each one fetched every custom preset from Bambu Cloud while holding a database connection; they now share one fetch. - **Library API: changing a file's folder could move it into or out of an external folder without moving the file** — The file then showed in a folder it was not in. That change is now refused with a pointer to the move endpoint, which moves the file itself. - **Slicing: a slow progress answer was reported as "Slicer sidecar unreachable"** — It now says the sidecar did not answer in time. - **Printer card: the cooling/heating mode switched on the printer showed the old mode** — The live updates did not carry it, so the card caught up only with its next refresh. - **Printer card: an H2C rack slot's material label disappeared** — The live updates replaced the rack without each slot's filament type, so the label fell back to the filament ID or went blank until the next refresh. - **Git backup: a print running when the backup was made came back as a failed print** — Restored as still printing, the archive was closed on the next printer reconnect as if the print had just failed, with a failure notification and a Print Log entry. It is now restored as cancelled ("No status update received"), the way Bambuddy closes a print it lost track of; an archive this instance still has keeps its own status on overwrite. - **Git backup: spools came back as never used** — When each spool was last used was not in the backup. It is now backed up and restored (an older backup leaves the value already there). - **File manager: a slow folder listing showed as "printer unavailable"** — Listing a large folder (such as the camera recordings) on a slow SD card can outlast the wait, and was reported like an unreachable printer; every refresh also started another listing while the last one still ran. It now says the listing is taking too long, and a refresh waits for the listing already running. - **Printer files from the evening could be dated a year back** — File times on the printer carry no year, and the year was taken from the host's local clock while printers report UTC. On a host behind UTC, files written in the evening were dated a year back, so their camera recordings were not matched to their prints. - **SpoolBuddy: writing a sticker could break a Bambu spool's AMS link in Spoolman mode** — A Bambu spool created from the AMS is linked by its RFID tray ID; it was listed as untagged on the Write Tag page, and a sticker written to it replaced that link in Spoolman, so the next AMS sync created a duplicate spool. It is no longer listed as untagged, and on a Spoolman that does not also keep the tray ID as a native tag (before 0.27) the write is refused with the reason. - **SpoolBuddy: Write Tag warned "color_name not set" for every Spoolman spool** — The warning looked at a Spoolman field that is never filled; Bambuddy keeps the colour name with the spool. It now warns only when the name is missing, and says the tag then carries the subtype instead. - **SpoolBuddy: an update reported success after installing its dependencies timed out** — The device was restarted while the install could still be running. A timeout now stops the update with an error (and the time allowed went from 2 to 5 minutes); a failed install still completes, and its output is now in the log. - **Virtual printer: a late slicer update could rewrite a queue job already being sent** — The slicer's print options can reach a virtual printer seconds after the job was queued. If the scheduler had already started sending the job, its AMS mapping and options were still rewritten, so the queue entry -- and the spool charged from it -- could differ from what the printer printed. Such a job is now left as it was sent. - **Backup restore: the virtual printers kept running during the restore** — The step meant to stop them did nothing, so their FTP, MQTT and discovery services stayed up while files were replaced. They are now stopped, and start again with the restart a restore asks for. - **API: the legacy virtual printer settings route answered 200 for settings it no longer applies** — `PUT /settings/virtual-printer` saved enabled, access code, mode, model, target printer and remote interface to settings nothing reads (each virtual printer keeps its own). It now refuses them with a pointer to `PUT /virtual-printers/{id}`; the archive name source and Tailscale switch still work there. - **Settings: removed "Save thumbnails" and "Notification Language", which did nothing** — Nothing read either: thumbnails were always stored, and notifications always used their template text as written. The API still accepts both fields. - **Restoring an old SQLite backup onto PostgreSQL could put some timestamps hours off** — Required timestamps the backup left empty were filled with the host's local time, while every other timestamp is stored in UTC, so on a host ahead of UTC those rows appeared hours in the future. They are now filled in UTC. - **Maintenance: a printer's time offline was counted as print time** — A printer that dropped off mid-print and came back printing hours later had the whole gap added to its runtime, so hours-based maintenance came due early. Time while Bambuddy itself was restarting is still counted, as before. - **Maintenance: duplicate maintenance items** — A printer's items are created when its maintenance is first shown, and two pages (or a page and the background check) could each create the same one; marking it performed could then update the copy not shown, and assigning that type again failed. Existing duplicates are merged on upgrade (the most recently performed one is kept, or the one the page showed when none was, with the history of all), and they can no longer be created. - **Maintenance API: "performed" answered in hours for an item set to days** — Its response used the maintenance type's interval type instead of the item's own setting, which the list already used. - **Notifications: the daily digest could be lost** — When the digest could not be sent (a network error, say), its events were deleted anyway. A digest time inside the provider's quiet hours was skipped, logged as sent, and its events deleted too. The digest now goes out at its time even during quiet hours, and a failed send keeps the events for the next day's digest (events more than two days old are dropped, so a provider that keeps failing does not pile them up). Lines from an earlier day show their date. - **Notifications: a later print could be attributed to the user of an earlier queued one** — When a queued print's archive was deleted while it was being sent to the printer, the record of who queued it was left behind, and an unrelated later print of the same name on that printer named and emailed that user. - **Notifications: "Print Stopped" after a printer came back online showed the time it was offline as the print's duration** — A print that ended while the printer was offline is closed when it reconnects; its real end time is unknown, so the notification now shows the sliced estimate instead of, say, 14 hours. - **Notification templates: the print outcome confirmation had no variables or preview in the editor** — The editor now lists its variables (including the confirmation link) and previews it, and seven other events (AMS humidity and temperature, bed cooled, the sensor alerts, auto-drying suspended, missing spool assignment) have their names. - **Configure Slot: a manual K value that could not be sent was reported as applied** — With no K-profile picked, a typed K value goes to the printer on its own command; when that send failed, the slot still read as configured. A warning now says the filament was set but the K value was not sent. - **A stray Stop on an idle printer could relabel a later failed print as cancelled** — Stop on a printer with no print loaded still marked it as stopped by the user. If the next print was first seen mid-way (after Bambuddy reconnected), that print's real failure was then reported as the user's cancel. The mark is now only set while a print is loaded (or the printer's state is unknown). - **Skip Objects and the printer connection diagnostic held a database connection through their printer I/O** — For a print started outside Bambuddy, the object list pulls the file over FTP, which on a printer mid-print can take a while; the diagnostic probes the printer and waits up to 10 seconds for a report. The connection is now released first. - **Camera light: the plate check could switch the light off under a live view** — With the camera light setting on, a live view opened while the print-start plate check had the chamber light on found it already lit, so it never took it, and the check then switched it off: the view went dark. The plate check now holds the light like every other camera use (and still lights the chamber whatever the camera light setting), so it goes off only once nothing uses the camera, after the usual short grace. - **External MJPEG cameras: timelapse frames, finish photos and Obico checks failed while someone watched** — A single-client camera (an ESP32 stream, for example) refused the second connection these opened while a live view was running. They now take the live view's frame, as they already did for the printer's own camera. A camera with a separate snapshot URL keeps using that URL. - **X1C live view could stop instead of reconnecting** — The printer ends each camera session about once a minute and Bambuddy redials; a background cleanup that ran at that moment could make the live view end instead. - **Camera test, diagnose, plate check and plate calibration held a database connection for the whole capture** — Up to 30 seconds each, which on a large farm could exhaust the connection pool. They now release it before capturing. - **API: `GET /queue?status=` with several statuses returned nothing** — The documented `?status=pending,printing` was matched as one status. Comma-separated statuses are now each matched. - **Saving an edited queue item showed "Queue item updated" twice** — The dialog also closed twice, a failed save showed two error toasts, and an edit that also added the job for further printers closed the dialog before those were added. It now reports and closes once, after everything is saved. - **A failed Unassign on the printer card said nothing** — Unassigning a spool from an AMS slot (built-in inventory or Spoolman) that the server refused showed no message, and the spool simply stayed assigned. The error is now shown. - **The OBS stream overlay ignored live updates** — It read the printer's status updates from the wrong field, so it moved only with its 2-second refresh instead of in real time. - **Uploading two files with the same name could show the wrong result per file** — When both were uploaded together, both rows showed the first file's result, so one that failed looked uploaded. Each row now shows its own file's result. - **The queue timeline and history rows showed plate 1 for every plate** — A queued plate of a multi-plate file appeared with the file's first plate as its picture, while the queue list showed the right plate. All three now show the queued plate. - **Inventory: removed the "Full" column** — It showed whether a spool had been added full, which nothing ever recorded, so every row read "-". - **Spoolman: the print dialog's cost estimate uses the Spoolman spool's price** — In Spoolman mode, the print dialog estimated each slot from the built-in inventory's assignments, which hold nothing in that mode or rows left from before the switch to Spoolman. The estimate (and the budget check that uses it) came out at the default price per kg or at a spool no longer in the slot, and the "g left" shown for each slot was wrong the same way. Both now come from the spool bound to the slot, priced the way the print is charged: the spool's own price, else its filament's, else the default. - **Spoolman: a print whose 3MF arrived late is charged from the 3MF** — A print that started before Bambuddy could fetch its 3MF (an "Untitled" print, a printer briefly unreachable) gets an empty archive that is filled in once the 3MF turns up. In Spoolman mode its filament was still charged from the AMS remaining-percentage change — in steps of 10 g on a 1 kg spool, nothing for a spool without a percentage, and nothing at all when no slot reported one at the start. The recovered 3MF's usage is now used, as the built-in inventory already did. - **Spoolman inventory: Cost/kg shows the price prints are charged** — A Spoolman spool priced only through its filament (the spool itself has no price, or a price of 0) showed "-" or "0.00" in the Inventory's Cost/kg column and no price on the SpoolBuddy spool card, although every print off it was charged that filament price. Both now show it, the column marked "(filament price)"; the spool's own price still wins, and editing the spool does not copy the filament's price onto it. - **Spoolman AMS syncs keep a spool assigned by hand, and say what they changed** — Three cases: - **A spool assigned to an AMS slot by hand** while the automatic Spoolman sync was waiting for Spoolman could be replaced by the spool the sync had read before. The syncs now change only the slots whose spool they found different, and only while the slot still holds what they read. - **The Sync buttons in Spoolman settings** changed slots without telling other open pages, and without updating the slot's filament preset, so the printer card could keep the previous spool's preset name. They now do both, as the automatic sync does. - **The Sync buttons** gave a newly created Spoolman spool the remaining weight of a built-in inventory spool last assigned to that slot before the switch to Spoolman, when the printer reported no weight of its own. That leftover weight is no longer used, as in the automatic sync. - **Spoolman inventory: five requests refused or reported correctly** — - **Deleting a storage location while Spoolman was unreachable** counted its spools from the built-in inventory, found none, and deleted a location Spoolman spools still used. The delete is now refused until Spoolman answers. - **Printing labels for an archived Spoolman spool** failed the whole batch with "not found", as Spoolman leaves archived spools out of its plain spool list. - **An archived Spoolman spool could be assigned to an AMS slot** (or put on one by a tag link) and then charged the next print's usage. It is now refused, as in the built-in inventory. - **A slot assignment or tag link for an AMS number no printer has** failed with a server error, and the link had already written the tag to Spoolman. Such a request is now refused before anything is written. - **Resetting the "Total Consumed" counters** reported success even when some or all of them failed. The result now says how many failed, as the other bulk actions do. - **A Git backup restore keeps "Reset Total Consumed"** — The backup did not include the point each spool's "Total Consumed" counter was reset at, so a restore brought every reset counter back to its full lifetime figure. It is now backed up and restored; a backup made before this keeps the reset points the spools already have. - **A finished print's filament is charged once** — Two cases in the built-in inventory: - **An AMS update right as a print finished** could raise the spool's used weight from the AMS remain% while the print's exact usage was still being worked out, and that usage was then added on top: the print was charged twice. The AMS sync now waits until the print is charged, as it does while the print runs. - **A print the printer reported finished without any file name** (one that failed while preparing, right after Bambuddy started) kept the AMS weight sync off until the next print started. It now turns the sync back on. - **Spool weights and slot assignments written at the same moment no longer undo each other** — Three cases: - **Assigning or unassigning a spool by hand, or adding a slot's spool to the inventory,** while the printer reported an AMS change for the same slot (or on a double-click) could fail with a server error on PostgreSQL. These now wait for the AMS update, as AMS updates already wait for each other. - **A weight set by hand or weighed on the SpoolBuddy scale** just as the AMS remain% sync ran could be overwritten by the AMS figure, with the weight still marked as locked. - **The same weight set at the end of a print** was replaced by the weight from before it plus the print's usage. The usage is now added to the stored weight. - **Inventory views stay current, and spools follow their printers and slots** — Five cases: - **Deleting a spool** left it shown in its AMS slot in other open tabs, and **a weight from the SpoolBuddy scale, the automatic AMS sync or "Sync Weights from AMS"** reached other open tabs and the printer cards only when they next refreshed. These changes are now announced like any other inventory change, and the printer cards also show the weight a finished print leaves straight away. - **Deleting a printer** on SQLite (the default database) left its spools assigned to it, shown as loaded on a printer that no longer exists. Their slots are now freed, and assignments left behind by earlier deletes are cleared at the next start. - **A spool assigned to an empty slot from SpoolBuddy** that the printer could not be told about when filament went in (for example while it was reconnecting) was marked done anyway and never configured. It is now tried again on the next AMS change. - **A spool CSV import** stored a `last_used` time with a timezone offset as if it were UTC on SQLite; it is now converted. - **Filament matching, preheat temperatures and colour names follow the plate's own filaments** — Three cases: - **An "Any model" job with a filament override** waited forever when the printers held an equivalent filament under another name (PA-CF loaded for a PA12-CF override). The type check already counted it as the same filament, but the colour check that picks the printer did not. - **Preheating a plate of a multi-filament project** heated the bed for the hottest filament of the whole project: a PLA plate in a project that also holds PETG was preheated, and kept warm between jobs, at the PETG temperature. Preheat and keep-warm now heat the bed for the filaments of the plate the job prints. - **Colour names on the queue page** came from the plain filament type, so a colour named differently in a product line (the same black is "Charcoal" in PLA Matte) showed the generic name. The queue page, and the colour name the print dialog saves with a filament override, now name it within the filament's product line, as the print dialog already showed it. Jobs queued before keep the name they were saved with. - **Stopping and cancelling leave nothing running or reserved** — Four cases: - **Cancelling a pipeline run** set its queued jobs to cancelled, but a job already preheating went on heating the printer (up to its soak time), and its share of the cost centre's budget stayed reserved. Both now happen as when the job is cancelled from the queue. - **Stopping a job while its printer is offline** left its share of the cost centre's budget reserved until Bambuddy restarted; it is now released at once. - **A printer slow to answer the end-of-print file cleanup** (up to about nine minutes) held up the end of the print so long that the stranded-job check closed the job first, and its auto power-off and its charge were skipped. The cleanup now gives up after 45 seconds and leaves the file on the card. - **A print start the printer refused** is now logged with the printer's reason, and the job goes back to the queue for a retry at once. Bambuddy used to read the refusal as a print it accepted and waited three minutes for it to start. - **Queue jobs added through the API reach the printers they should** — Three cases in the API and webhooks: - **A model in lower case** (`"target_model": "x1c"`) was refused with "No active printers for model", although the scheduler matches models without regard to case. Adding (one job, alternatives, or library files), editing and pipelines now match it the same way and store the printers' own spelling (`X1C`); two alternatives for one model in different case are refused as the same printers. A pipeline aimed at a model class in lower case found no printers. - **A reprint through the webhook** (`/webhook/queue/add`) of a print sent through the virtual printer ignored the AMS slots the slicer had picked, so the scheduler matched slots by colour and type again. It now reuses them on the printer they were picked on, as a reprint from the UI does. - **A multi-plate file queued without a plate** (`/library/files/add-to-queue`, `POST /queue/`) prints its default plate, but the scheduler required every plate's filament, so a printer with that plate's filament loaded was never chosen, or the job was held for another plate's filament. The plate that prints is now stored on the job. - **Queue edits made while the queue is being worked through are kept** — The scheduler works on the queue as it read it at the start of each round, and a round can take minutes while a smart plug powers a printer on. A job edited in the meantime lost the edit: an AMS mapping set by hand was overwritten, a job moved to another printer (or an "Any model" job pinned to one) could still be sent to the printer the round had picked, and a new plate could be mapped from the old plate's filaments. A job edited during a round now waits for the next one, which reads the edit. A bulk edit likewise leaves a job that started dispatching while the edit was checked, instead of changing its printer or cost centre under the upload. While waiting for a printer to power on, the scheduler no longer keeps a database transaction open. - **A print's filament and cost are charged with its own plate and AMS mapping** — Bambuddy remembers per archive which plate and AMS slots a dispatch uses, for the usage and cost charged when the print ends. Three cases used the wrong ones: - **A whole file queued after one plate of it** was sent but never started (refused, or the printer stayed idle) was charged as that plate, with its slots. - **A plate that failed to send** wiped the plate number and slots of another plate of the same archive still printing, whose end then charged the whole file. - **A reprint dispatched without a slot mapping** could be charged through the previous run's slots, when that run's printer had reported its own mapping. - **A timelapse goes to the print it belongs to** — Several cases gave an archive the wrong video, or none: - **The next plate or a reprint started meanwhile:** the printer's timelapse is looked for for some minutes after a print ends. When the same archive started printing again in that time, the previous print's video went to the new print, and the new print's own video was then never imported. The search now stops once the archive is printing again, and nothing is attached or deleted from the printer. - **Scan for Timelapse on an older archive** (from before the video list was recorded at print start) could pick a video another archive already has, for instance by a short name like "1" that every video's timestamp contains. Those are now left out of the automatic match, and are still offered for picking by hand. - **Both the external camera's and the printer's timelapse on:** the archive keeps the external camera's video, as before, but the printer's video it replaces is now deleted instead of staying in the archive folder unused. A camera video stitched for a print whose archive is already printing again (the next plate, a reprint) is no longer attached to the new print. - **Archives pages show a timelapse as soon as it changes** — Only the automatic scan told other open pages; a video picked or uploaded by hand, the external camera's, the conversion to MP4 and deleting one showed only after a reload. - **An archive cost set through the API, or by Recalculate costs, reaches the statistics** — Setting an archive's cost (`PATCH /api/v1/archives/{id}`) or **Recalculate costs** changed the archive only, while statistics, project totals and batch sums read the cost from the print log, so they kept the old figure. A completed print that had the archive's cost now gets the new one too; a print priced from its own spools, and a failed or cancelled one, keep theirs. **Recalculate costs** still recalculates a cost that was set by hand, as before. A negative cost, or one over 1,000,000, is refused. - **A request with `Infinity` or `NaN` in a field that fails validation answers 422** — Such a request ended in a server error, because the error response repeats the value sent and JSON can't hold it. - **Editing an archive while it is being reprinted leaves the previous print alone** — Its outcome, failure reason, verdict and filament weight went onto the previous print's log entry, rewriting that print's result in the print log and failure statistics. The edit now changes the archive only. - **Archives made without their 3MF show their colours and AMS-HT filament** — When a print's 3MF couldn't be downloaded at start, the archive took its filament from the printer's AMS report. Colours were stored as the printer reports them (`FF8800FF`), so their swatches on the Archives page stayed blank and the colour filter listed them apart from the same colour of other archives; they are now stored as `#FF8800`, like an archive with its 3MF, and existing archives and their print log entries are repaired on startup. Filament the print took from an AMS-HT was not found, so a print using only an AMS-HT got no filament. - **An archive whose 3MF arrives later is priced from its spools** — Such an archive started with the default filament price, having no 3MF to estimate from at print start, and kept it when the 3MF was found during the print. It is now estimated from its spools then, as at print start; a 3MF found only as the print ends leaves the pricing to the print's end, as before. - **An archive shows the nozzle temperature of the filament its plate prints** — The temperature was taken from the project's first filament, so a plate printing only PETG showed the PLA temperature of filament 1. It is now that of the plate's first used filament. - **A print stopped while its printer was offline counts in the print log, and its real end replaces the stop** — When **Stop** couldn't reach the printer, the queue item and archive were closed as cancelled, but no print log entry was written, so the run was missing from the print log and failure statistics, and the Archives page kept showing it as printing until reloaded. The stop now writes the cancelled run and updates the Archives page. Should the printer report the print's real end on reconnect, that outcome replaces the cancelled entry, and the archive no longer shows **Completed** with the reason "Cancelled by user". - **One entry per run:** a printer reporting the same end twice (after a reconnect) logged the run twice, doubling it in the statistics. The repeat is now left out. - **A reprint's reason:** an archive whose earlier run failed kept that failure reason when a reprint completed. Each run's end now sets the archive's reason, or clears it. - **A print Bambuddy didn't start is matched to its own archive** — At its end, such a print was matched to an archive whose name merely contained its name, so finishing "Clip" could complete (and bill) a "Cable Clip" still marked as printing; `_` in a name also matched any character. It now needs the same name, allowing for the printer writing spaces as underscores, cutting long names short, or reporting the file name for a print started on its screen. - **Print start picks the print's own 3MF from the printer's folders** — When the 3MF couldn't be downloaded directly, Bambuddy searched the printer's folders and took the first file containing the print's name, so "Cube" could be archived with "Cube_large.3mf". A file named for the print is now taken, in whichever folder, and of several files that only contain the name, none. - **The A1, A1 Mini, P1 and A2L built-in calibration is not archived as a print** — These printers report their calibration run as a bare `auto_cali_for_user_param.gcode`, not under the system path other models use, so Bambuddy archived each run as a print (without a 3MF) and announced it. It is now recognised as the printer's own job. The printer kill switch still stops it on these models, since nothing tells it apart from a file given that name: switch the kill switch off to calibrate them. - **A notification channel limited to one printer gets that printer's events only** — A job added for **Any ** or not yet assigned, and any job waiting (for filament, a plate, a printer), were sent without a printer, so no printer filter applied: every channel limited to a printer received all of them, other printers' included. A job that waits on a printer now names it, and reaches that printer's channels; one with no printer yet goes to channels set to all printers, and a channel limited to one printer hears of it once it is assigned to or started there. A power alert from a plug linked to no printer likewise goes to channels set to all printers. Events about the whole setup (the queue finishing, stock, app messages) still reach every channel. - **Queue drying pre-dries in blocking mode** — With **Wait for drying to complete** on, a new drying cycle was refused on any printer with a pending job, scheduled ones included, so queue drying never dried anything ahead of a scheduled print. Only a job that could start now keeps a new cycle off; a printer whose job is scheduled for later is dried ahead of it, and the job still waits if the cycle has not finished by then. - **An "Any model" job waiting for a drying printer says so** — It read "Busy: X1C-01", as if that printer were printing; it now reads "Busy: X1C-01 (drying)", as a job for that printer does. - **An H2D shows the right active slot after a filament change** — An H2D reports its active slot as a bare number and names the AMS in each nozzle's own data, often in the same message. That message's AMS part was read first, against the previous message's nozzle data, so after a swap the card (and usage tracking) could keep pointing at the old slot until the printer reported its slot again. The nozzle data is now read first, and nozzle data from an earlier message no longer overrules the slot the printer just reported. - **Home Assistant "Run when printer turns off" scripts run when Bambuddy switches the printer off** — Linked scripts ran only when someone switched the printer's plug by hand. Auto-off after a print or drying (also when resumed after a restart), the plug's schedule and AI failure detection turned the printer off without them, and the schedule turned it on without its "on" scripts. They now run on all of these. "On" scripts keep running at every print start as before. For these automatic switches only a plug that powers the printer counts, the scripts run once when two plugs feed the printer, and a script whose **Enabled** toggle is off does not run (by hand either). Failure detection no longer tries to switch off a script. - **A plate check that could not run says so** — When the camera gave no image, the analysis failed or there was no calibration to compare against, the check before a print reported an empty plate, logged "Plate is empty" and told nobody. The print still goes ahead, but users who can control printers now get a warning with the reason, the log records it, and **Check Plate** shows it instead of "Plate appears empty". - **The archive recovery download gives a large 3MF time** — The retry that fetches a print's 3MF when the first download failed gave up after a flat 90 seconds, so a large file on a slow SD card never made it. Once the printer reports the file's size, the deadline follows it (up to 15 minutes), as the first download's already did. - **A broken MakerWorld download is not kept** — Only the HTTP status was checked, so a truncated file or an error page served as a success was stored as the model's 3MF. It is now refused unless it is a readable 3MF. - **An upload the printer never confirmed is checked** — When the printer's "transfer complete" reply does not arrive (an H2D can take over 30 seconds), the upload still counts, but its size is now read back on a new connection and a mismatch is logged as an error. - **The queue bulk edit says when it changed nothing** — The result was a neutral, English-only message even when no job was updated. It is now translated, and shown as an error when nothing was updated and as a warning when some jobs could not be changed. - **CallMeBot gets the phone number as written** — The phone number and API key went into the request unencoded, so "+49 151 ..." arrived without its "+". - **A plate printed only from the external spool starts without the AMS on multi-plate files too** — The queue already sends such a plate with the AMS off (#3087), but when another plate of the same file used an AMS slot, the print command turned the AMS back on and the printer refused the job, also for a job whose AMS was already switched off. The plate's own answer now stands. - **A Silk spool no longer takes a slot's Matte preset** — A spool without a slicer preset reuses the filament preset already in the slot when the material matches, to keep a calibrated preset, and so does **Configure Slot** for a preset without a filament id. That now also requires the finish to match when both name one (Basic, Matte, Silk, HF, ...); otherwise the slot gets the generic preset for the material and finish, such as Generic PLA Silk for a Silk+ spool. The generic fallback picks the finish's own generic in Spoolman mode too (Generic PLA Silk, PLA High Speed, PETG HF). - **A virtual printer for any printer of a model keeps the AMS on** — It stored the slicer's AMS on/off with the job. The slicer was looking at the virtual printer, which has no AMS, so that choice did not describe the printer the job went to. Only a virtual printer with a target printer keeps it now. - **The K-profile filament names from Bambu Cloud read a preset's id from either place** — The name lookup read a cloud preset's filament id only from the response envelope, while slicing also looked inside the preset itself; a preset with the id only there showed its raw id. - **A K-profile chosen on purpose is no longer undone when the AMS reports a change** — When a spool with a stored K-profile sat in a slot, an AMS report that changed the slot (its remaining amount, for example) set it back to the stored profile, so one picked in Bambu Studio or on the printer's screen did not last. That re-apply is gone: the existing lost-profile check puts the stored profile back when the slot has lost its selection (the default K after a reset or power cycle) while the printer is idle, and leaves a deliberately chosen profile, a chosen **Default** and the other nozzle's calibration alone. - **K-profile commands no longer change the shown nozzle size** — The printer's answer to saving, deleting or selecting a K-profile names the nozzle size of that profile, and Bambuddy took it as the installed nozzle. Only the printer's status updates the nozzle now. - **A K-profile save says when the printer did not confirm it** — A save, batch save or delete the printer did not acknowledge was reported as done. It now says the profile was sent but not confirmed (older firmware never answers), and an import or bulk delete says how many of its writes went unconfirmed. An edit on a single-nozzle printer, which removes the profile and adds it again, reads the printer's table back and fails when the edited profile is not there. - **Stopping drying says the command was sent** — The toast said "Drying stopped" as soon as the command went out; it now says "Stop command sent". - **A skip the printer refuses or never applies is logged** — Skipped objects were marked skipped the moment the command was sent. A refusal from the printer now takes them back off the skipped list and is logged with the printer's reason, and a skip the printer has not listed after a minute is logged as unconfirmed. - **The printer card shows a nozzle change, firmware update or developer-mode switch at once** — These were left out of the live updates and only reached the card with the 30-second refresh or a page reload. - **The print slot labels on AMS slots show up** — While a print runs, each AMS slot the print uses is meant to carry a small "P1 / P2" label, so a job sent to a printer with the wrong filament stands out. The labels read the print's mapping from the printer's own status, which almost never carries it, so they rarely appeared. Bambuddy already records the mapping from the print command, and the labels now use it. They stay on when Bambuddy is restarted while the print runs. A print started from the printer's screen after a job that never started (refused, or cancelled before printing) no longer inherits that job's mapping, which usage tracking read as well. - **Drying no longer offers spool rotation on an AMS with filament in the feed tube** — **Rotate spool during drying** was disabled only for the tray state that means "loaded" on some firmware, so on others it stayed available and the printer refused the drying start. The slot the printer reports as loaded, and on two-nozzle printers the slot each nozzle is fed from, now count too. - **The run-out hint names the slot on two-nozzle printers with several AMS** — When a print paused for filament, the hint often said to check the printer screen, because the slot number alone does not say which AMS it is on. The AMS wired to the active nozzle now settles it; with the Filament Track Switch, where an AMS can feed either nozzle, the hint still asks. - **A regular AMS is never shown as an AMS-HT** — An AMS was taken for an AMS-HT when it reported a single tray. The unit number (128-135 for AMS-HT) now decides, on the printer card and its slot labels ("HT-B" became "AMS-B"), in the AMS Filament Backup view, print mapping, the slicer's loaded-spool list and the queue. A regular AMS that reports only some of its trays shows each one in its own slot rather than in list order. - **A print stopped by the printer kill switch is archived as cancelled** — The printer reports a stop as failed, and only stops made from Bambuddy were turned into cancelled, so every print the kill switch stopped went into the failure statistics as a failed print with no reason. It is now cancelled, with the reason **Stopped by the printer kill switch**, which the archive editor also offers. - **A queue job stopped while its printer was offline is counted as a user cancel** — Its archive stored an English sentence as the failure reason, which showed untranslated and counted as a cause of its own in the failure statistics. It now stores **User cancelled**, like every other stop; existing archives are converted on startup. - **Saving the SpoolBuddy backend config says it needs a restart** — The device writes a new backend URL or API token to its config file but keeps using the old ones until its daemon restarts, while the settings page only said "Config queued." It now says a restart applies it and offers **Restart Daemon**, which waits until the device has taken the config so the restart cannot replace it. - **A re-sliced archive reads as not printed and shows a cost** — Re-slicing an archive created a new archive marked "completed", so it appeared under **Printed** and was counted as a finished print although nothing had printed. It is now "archived" (shown as **Not printed**), like any file archived without printing, and it shows an estimated filament cost like other archives with grams. - **A reprint of a single-plate archive no longer leaves the old timelapse behind** — A timelapse attached by hand, or before Bambuddy recorded plates, was always kept as an unlinked file on a reprint, since on a multi-plate archive it can be another plate's only copy. On an archive with one plate there is no other plate, and it is now deleted with the run it belonged to. - **A GitHub backup restore checks the numbers it restores** — Grams, costs, energy, print times and spool-usage weights were copied from the backup as they were, so a damaged or hand-edited backup could put text, negative or absurd values into the totals. Anything that is not a valid number (grams at most 100000, as the archive editor allows) is left empty, and the restore summary says so. A quantity of 0 (a ruined plate) is kept instead of becoming 1. - **A print resumed after a restart keeps its plate height** — Each way Bambuddy picks up a running print (new archive, expected print, restart resume) remembered it under a different set of names. When the printer later reported a name that path had left out, the finished print was still found but lost its binding, and with it the height the plate restore uses after a restart. Every path now remembers the same names. - **An "Any model" job gets a fresh nozzle mapping on a nozzle-rack printer** — A nozzle mapping stored on a model-based job (through the API or an order's plate spec) was sent unchanged to whichever H2C picked it up, instead of the rack positions of that printer. It is now worked out for the printer that takes the job. On printers without a rack, such as the H2D, where the mapping means left and right nozzle, it is kept. - **A used weight entered for a new spool is kept** — Adding a spool (one, several with **Add copies**, or from a CSV) left its weight unlocked even when a used weight was entered, so the automatic AMS sync could overwrite a part-used spool's weight on its first pass. A used weight entered at creation now locks the spool, as editing the weight already did. A negative used weight is refused; more than the label weight is still accepted, since a mis-labelled spool really can give more. - **Spoolman's "last used" means a print used the spool** — Every AMS sync stamped *last used* on every loaded Spoolman spool, as did clearing a removed spool's location and weighing on the SpoolBuddy scale. Only print usage sets it now (through Spoolman's own usage endpoint), as in built-in inventory. - **The AMS slot K-profile picker accounts for the nozzle's flow** — On printers that report a high-flow or standard nozzle (H2-series), the picker offered and pre-selected K-profiles measured on the other kind, which are not applied when the slot is configured. They now sit under **Other**, and every profile shows its flow ([HF] / [S]), as in the spool form. - **Printer temperature history retention can be set, and no retention setting can wipe history** — How long the nozzle, bed and chamber temperature history is kept had no setting in the interface; it now sits next to the AMS history under **Settings → Filament → History Retention**. Both settings take at least one day: 0 or less was accepted through the API and made the daily cleanup delete all history, and a value stored that way is now read as one day. - **Bug reports no longer keep the reporter's email** — Every report sent from Bambuddy was also stored locally with the email it was sent with, and nothing ever read or removed it. The email still goes with the report; the local record keeps only the outcome, and emails stored before are cleared at startup. - **Unused API pieces removed and the rest documented** — The firmware upload no longer broadcasts a WebSocket progress message nothing listened to (the page polls the upload status, as before); the old route that deleted every plate-detection calibration of a printer at once is gone (references are deleted one at a time from the plate-detection dialog); `/spoolbuddy/calibration` now opens the SpoolBuddy settings page, where calibration lives. The wiki now documents `POST /archives/upload-source` for slicer post-processing scripts and `PUT /local-presets/{id}`. - **Load and Unload work on an AMS-HT** — Clicking **Load** on an AMS-HT slot was refused with "tray_id must be 0..15 …": the printer card numbered the slot like a regular AMS (unit × 4 + slot, 512 for the first AMS-HT), and the printer's commands would have turned the AMS-HT into a unit it does not have. **Unload** while the AMS-HT was the loaded tray was addressed to that same non-existent unit. Both now address the AMS-HT by its own unit number, as Bambu Studio does, and **Unload** from an AMS-HT slot's menu is aimed at that slot. Loading from the A2L's AMS-Lite now sends the target Bambu Studio sends as well. - **A second Bambuddy on the same machine no longer cuts the camera every minute** — Bambuddy's once-a-minute cleanup kills camera ffmpeg processes left behind by a crash or restart, and recognised them by their command alone. Another Bambuddy on the same host (a second install, a test instance) runs the very same command, so each one's cleanup killed the other's live camera once a minute and the view reconnected. Only an ffmpeg started by this Bambuddy, or left orphaned by an earlier run, is cleaned up now. - **A restart with a camera view open no longer logs errors** — A camera stream never ends by itself, so a restart closes it after 5 seconds. Each stream closed that way was logged as an ERROR with a full traceback ("Exception in ASGI application ... timeout graceful shutdown exceeded"), which looked like a crash in the log and in support bundles. It is now one INFO line per stream, and the "Cancel N running task(s)" line before them is INFO too; real errors are logged as before. - **Archive export includes the whole end day, and an invalid print speed is refused** — `GET /api/v1/archives/export?date_to=2026-10-15` read the date as midnight and left out everything printed that day; a date on its own now ends with that day, as in every other date filter, and a date and time with a UTC offset is converted to UTC. The Archives page was not affected, because it never sends dates. `POST /api/v1/printers/{id}/print-speed` with a mode other than 1-4 answered 500 "Failed to set print speed"; it now answers 422. - **AMS drying now applies to "Any " queue jobs** — With **Wait for drying to complete** on, a job for "Any X1C" could still start on an X1C whose AMS was drying, and its drying was never stopped for the print; queue auto-drying also never dried anything for such a job. Now a model job passes over a drying printer for another of the model, and waits only while every one of them is drying. The printer it lands on stops drying like it would for a job pinned to it, and a scheduled model job has queue auto-drying dry every printer it could run on. - **Every way of adding a queue job now works like the print dialog** — Jobs added from File Manager's **Add to queue**, the webhook, a virtual printer, a pipeline run or an order's **Dispatch remaining** sent no "Job added" notification and no MQTT `queue/job_added` event; now every path sends them. Several jobs added at once are one notification per printer, such as "3 jobs (bracket, hinge, lid)", and MQTT gets one event per job. File Manager, the webhook and pipeline runs ignored the default print options (timelapse, bed levelling and the rest) and now use them. The copies an order dispatches lost their cost centre, so they were billed to none and never checked against its budget; they now keep it, and with billing on, dispatching is refused when you may not print with that cost centre or its budget cannot cover every copy. A `scheduled_time` sent to the API or webhook with a UTC offset, such as `10:00+02:00`, was stored as 10:00 UTC and the job started two hours late; the offset is now converted. - **Long prints keep their layer timelapse** — Stitching a layer timelapse was stopped after 5 minutes and its frames were then deleted, so a print with more than a few hundred layers on a Raspberry Pi lost its timelapse for good. Stitching now gets half a second per layer, from 5 minutes up to an hour. If ffmpeg fails anyway, or Bambuddy stops while it runs, the frames are kept for about 24 hours with an ffmpeg command to make the video by hand; when ffmpeg fails, the log names the folder. Without ffmpeg the frames are still deleted. - **An in-app update that hangs no longer blocks further updates** — A stalled `git fetch`, `pip install` or `npm install` left the update showing "downloading" or "installing" forever, and every new attempt was refused with "Update already in progress" until Bambuddy was restarted. Each step now has a time limit (5 minutes for git, 15 for pip and npm); a git or pip step that runs out of time ends the update with an error, and checking for updates again lets you retry. When pip or `git reset` is the one that ran out of time the new code may already be in place, so run the update again before restarting. A frontend build that runs out of time keeps the files shipped with the release. - **The plate picker downloads a printer's 3MF once** — Opening a 3MF in the printer's file browser downloaded the whole file again for the plate list and for every plate thumbnail, all at once: a ten-plate 60 MB file meant eleven full downloads, slow on any printer and slower still on an A1. The plate list, plate thumbnails and G-code preview now share one download and keep it for a minute (the 3D view still fetches its own copy). - **Printer file downloads tell a missing file from a failed one, and big files get the time they need** — Downloading a file from the printer, and the file browser's previews (G-code, plates, plate thumbnails), treated every failure as "File not found", including a slow transfer of a file just listed. Bambuddy now answers "not found" only when the printer says the file is gone, and otherwise that the download failed or took too long; the download's message and the timelapse messages show which. A file download was also held to 10 minutes whatever its size, so a 250 MB camera recording on a slow link always failed, and the previews to 5 minutes; both may now take as long as the file's size calls for, up to the 30-minute limit. A timelapse that could not be downloaded is reported the same way instead of as a server error. - **A timed-out printer download no longer runs on beside its retry** — When a timelapse download ran out of time, it kept going in the background while the next attempt opened a second connection to the printer. The transfer is now stopped first, and a download that already had the time its size allows is not retried. - **Large STL files no longer freeze Bambuddy while their thumbnail is made** — Uploading an STL, extracting a ZIP of STLs, generating missing thumbnails and scanning an external folder rendered each thumbnail in a way that held up everything else, so a high-poly model froze the web interface and printer updates for seconds or longer. Thumbnails are now made in the background, one at a time. - **A frozen external RTSP camera is reconnected** — When an external RTSP camera stops sending, `ffmpeg` can keep repeating the last picture, so the live view and `/camera/status` kept showing a picture that had stopped changing, and so did anything reusing the live frame (timelapse frames, finish photos, Obico). Like the printer's own camera, an external RTSP camera that sends the same picture for 20 seconds is now reconnected. A camera that is simply slow to change (a slow source, or a smart codec on a static night scene) is restarted at most once, after which Bambuddy waits three times the longest gap it has seen, up to 5 minutes; a picture that really does not change is re-checked every 5 minutes. - **A USB camera can be watched from several places at once** — Every viewer of an external camera opened its own connection to it. A USB camera can only be read by one program at a time, so opening the Camera page while a Cam Wall tile (or another browser) was already showing it gave a blank picture. External cameras now share one connection per printer, like the printer's own camera, which also means one connection per MJPEG or snapshot camera and one `ffmpeg` per RTSP or USB camera instead of one per viewer. As with the printer's own camera, the viewer who opens the connection sets its frame rate: with a Cam Wall tile already showing the camera, the Camera page gets the tile's 8 frames per second until everyone has closed it. - **Camera snapshots and the camera test no longer fail while someone watches the live view** — A USB camera can be read by one program at a time, so while a viewer was watching, every snapshot Home Assistant or the camera wall fetched from an external camera failed, and the external camera's **Test** button failed too. Snapshots now return the live view's frame, and the Test button reports the camera working from that frame. While the live view is still starting, a snapshot asks the caller to try again a moment later instead of taking the camera from the viewer (the camera wall and the camera page now retry once on their own); this now applies to the printer's own camera as well. An external camera with its own snapshot URL is still fetched from that URL. - **USB camera photos are no longer killed mid-capture** — A cleanup job that runs every minute stops leftover camera processes, and it did not know about the one-shot USB capture behind finish photos, timelapse frames and snapshots, so it sometimes killed one in progress. It also stopped any other program's `ffmpeg` reading a USB camera on the same machine; it now only stops Bambuddy's own. - **The camera Test button shows the resolution properly** — It read "Camera connected640x480"; it now reads "Camera connected (640x480)". - **Admins can remove a locked-out user's 2FA from Edit user** — The wiki described this, but Edit user had no control for it, so a user who lost their authenticator stayed locked out unless an admin called the API by hand. **Settings → Users → Edit user** now has **Disable 2FA**, which asks for the admin's own password (admins who sign in through LDAP or SSO usually have none and are not asked) and says whether the user had any 2FA to remove. - **The AMS drying button knows a printer cannot dry from the first page load** — After a page load and every 30-second refresh, the printer card didn't know the drying status or why drying wasn't possible, so a printer that cannot dry showed an enabled Start button until the next live update (the printer still refused the start). The page load and the live updates now describe AMS units and external spools in exactly the same way. A slot the firmware reports as only its number now also counts as empty from the first load, and the AMS temperature no longer switches between "24°C" and "24.0°C". - **The filament override picker keeps both external spools of a dual-nozzle printer without an AMS apart** — On an H2D or X2D feeding only from its external spools, identical spools on the left and right merged into one entry that was offered for both nozzles. The picker now uses the same dual-nozzle check as the print scheduler, so each external spool is offered only for the nozzle it feeds. - **A2L: filament used from the AMS Lite is charged to the right spool** — The printer names an AMS Lite tray by its local slot 1-4, and Bambuddy kept that number when it recorded which trays a print used, so the AMS Lite spool was never charged. With a regular AMS beside the AMS Lite, the usage went to that AMS's slot of the same number instead. A virtual printer with **Save AMS mapping** on stored the same local number for queued prints and their archives; with a regular AMS present, such a print was sent to that AMS instead of the AMS Lite. - **Proxy virtual printers no longer restart when another virtual printer is saved** — Creating, editing or deleting any virtual printer restarted every running proxy-mode virtual printer, dropping the slicer's connection and any upload in progress. They now restart only when their own settings change. Changing a printer's IP now restarts the proxy virtual printers aimed at it on the new address; before, they kept forwarding to the old one until a virtual printer was saved. Renaming a virtual printer now restarts it, in every mode, so the slicer sees the new name; before, a running virtual printer kept advertising its old name until Bambuddy restarted. - **Prints queued through a virtual printer keep the slicer's nozzle choices** — - **Nozzle offset calibration** from the slicer, or the default from Settings → Workflow, is used. It always ran on "Auto". - **H2C rack nozzles** picked in the slicer are kept only by a virtual printer bound to one printer. A virtual printer for any H2C sent those positions to whichever H2C took the job, whatever that printer's rack held; the dispatch now picks them from that printer's rack. - **A job the queue moves to another printer**, or releases back to the pool, no longer takes the old printer's nozzle positions along. - **AMS on or off** from the slicer is used when the slicer's options arrive in time; it was used only when they arrived late. - **A slicer AMS mapping that arrives late** is no longer put on a plate of a multi-plate send that it cannot reach; that plate gets a computed mapping, as when the options arrive in time. - **Settings that were not saved or not used** — - **Max copies per run** (Workflow) and the Docker compose directory used in the update instructions are saved. They changed on screen and were never sent, so a 100-copy pipeline run stayed refused at the old limit of 50. - **Default printer** (General) is now selected in the print dialog when it opens, unless the file was sliced for another model or the job goes to a model rather than a printer. The setting was saved and never used. - **Auto-archive prints** (General) now switches archiving off for every printer, for prints started outside Bambuddy. While it is on, each printer's own switch decides. Before, only the printer's switch was read. A printer's own calibration runs no longer send a "print started" notification while archiving is off. - **Create Archive Entry When Printing** (File Manager) is removed. It was saved and never used: printing from the File Manager always creates an archive entry. - **A spool Spoolman creates from the AMS is the size of the roll** — a 250 g refill matched to an existing 1000 g filament was created as a 1000 g spool with 250 g left, showing as 75% used. - **An unregistered SpoolBuddy that is still running registers again on its next heartbeat**, as the documentation says. It stayed missing until the daemon or the Pi restarted, and the refused heartbeats filled its send buffer, which then held back everything queued behind them. - **SpoolBuddy installer, full mode, installed under /home** — the Bambuddy service it writes now starts. The service hid /home from itself and failed with 203/EXEC, as the main installer's did before #1685. - **Spoolman costs** — - A failed or stopped print in Spoolman mode is logged in Print History with what its Spoolman spools cost for the filament it used, as with the built-in inventory. It was logged with no cost. - The estimated cost of a queued library file is priced from the Spoolman spools in the trays it maps to. Every slot was priced at the default filament cost. - **Spoolman usage** — - A failed or stopped single-filament print that switched to the AMS backup spool charges each Spoolman spool its share, as a completed print and the built-in inventory do. All of it went to the spool that ran out. - Two copies of the same archive printing at once on two printers are both charged to Spoolman. The second print's start failed on the shared archive and that print was never deducted. - A filament slot the print does not feed is no longer charged to the external spool, or to whichever AMS tray sits at that position, as with the built-in inventory. - **Spoolman slots** — - Assigning or linking a Spoolman spool to an AMS slot is now checked against what the printer reports back, as with the built-in inventory. A printer that ignored the new filament showed no "not confirmed" notice, and later usage went to the wrong spool. - Configure AMS Slot opens with the slicer preset set on the Spoolman spool when no per-printer-model preset applies, and the slot's K profile is matched under it. Before, the dialog opened with no preset and the K profile was matched against the tray's own filament. - **Spool slots** — - Archiving a spool, one or in bulk, unassigns it from its AMS slots, with the built-in inventory and with Spoolman, and slots still held by spools archived earlier are released on update. The slot kept showing the archived spool and charged it the next print's usage. A Bambu Lab spool's tag still in the AMS after archiving is not given to another spool or turned into a new one. With Spoolman, a spool feeding a running print keeps its slot until the print's usage is reported. - Deleting a Spoolman spool removes its slot assignments too, and a spool archived or deleted in Spoolman itself releases its slots after the next print on that printer. The slot kept pointing at a spool Spoolman no longer had. - Printer cards and slot lists update right away when a Spoolman spool is archived, deleted or unassigned. - **Sync** and **Sync all** in Settings → Spoolman no longer unlink a slot that reads empty while its printer is printing (a runout), or one the AMS still reports as holding a spool. - The spool list in the Assign dialog updates right after a Spoolman assignment, so the spool just assigned is no longer offered for a second slot. - **Restore** on an archived spool inside an expanded group row of the inventory table works. It did nothing. - **Spool weights from the AMS** — - **Sync Weights from AMS** and **Sync Spoolman Weights from AMS** leave the spools of a printer that is printing alone, and say how many. Synced halfway through a print, that print's usage was counted twice at completion. - A spool weighed on the SpoolBuddy scale is locked like one whose weight was set by hand, so the automatic AMS remain% sync no longer overwrites the measured weight. - **Sync Weights from AMS** now also updates locked spools and releases their lock. Nothing else could release it, so a spool whose weight was once set by hand was skipped by the sync for good. - **SpoolBuddy** — - The Write Tag page no longer stays on "Writing tag..." when the tag was written but could not be linked to the spool. It shows why: Spoolman not set up, linking in Spoolman failed, or the spool no longer exists. - A scanned tag that cannot be looked up because Spoolman is unavailable, or because the lookup failed, now says so in the kiosk status bar for a few seconds. Before, nothing happened. - With Spoolman switched on and its URL refused by Bambuddy's address check, tag lookups, tag writes and scale weights no longer fall back to the built-in inventory, where they found or changed a different spool. They report Spoolman as unavailable, and Write Tag says so. Switched on without a URL still means the built-in inventory, as everywhere else. - Removing a SpoolBuddy device updates the device list right away. - **Archive status in Edit Archive** — the list offers Completed, Failed, Aborted and Cancelled. Choosing "Cancelled" used to save "aborted", which the statistics count as a failure, and a cancelled archive opened showing "Completed". An archive can no longer be set to "Printing" by hand, which had it closed again at the next reconnect as a missed print, with notifications and an extra Print Log entry. The archive card labels an aborted print "aborted" and now also marks a cancelled one, which looked like a normal completed print. Moving an aborted print to Cancelled keeps its recorded reason; marking a cancelled print Completed clears it. - **Archive costs** — - **Recalculate Costs** and **Rescan** price one print of the archive. An archive printed three times was given the cost of all three. Usage recorded without a price is priced at the default filament cost instead of counting as free. Print History and the statistics still count every print with its own cost. - Editing an archive's filament weight now reprices it, on the card and on the print it was copied to. - A spool without a price is charged at the default filament cost when the print completes. With the default never saved, completion priced it at nothing while the Settings page showed 25 per kg, and Recalculate later used 25. - **A print of a file whose last print was never closed gets its own archive** — an archive left on "printing" because its end was missed used to take over the next print of the same file for two hours, so that print got no history entry, timelapse or cost estimate of its own. A different job id from the printer now marks it as a new print, except when it is Bambuddy's own reprint of that archive. - **Filament of a print that spans a Bambuddy restart is no longer counted twice** — when the restart found no saved print session, or one of another print, the AMS remaining-% sync raised the spool's used weight during the print, and completion then added the print's full usage on top. - **A print whose dispatch failed no longer leaves its cost centre behind** — the next print of the same archive without a cost centre of its own was charged to it, instead of the user or the archive's cost centre. - **Which plate of a multi-plate file printed** — - Print History logs the filament and cost of the plate that printed. A reprint of another plate of the same file used to log the first plate's estimate. - Filament usage after a Bambuddy restart mid-print is charged for the plate the queue job printed, not for the plate of the archive's first print. - A print started outside Bambuddy records its plate on the archive when the printer reports it, so reprints, usage and Print History use that plate instead of the first one or the whole file. - The archive's thumbnail and details describe the plate Bambuddy sent, also when the printer reports only the file name or names another plate. - **Reprints and their archives** — - A reprint that completes no longer keeps showing why the previous run of the same archive failed (for example a clogged nozzle). - A queued job whose archive was deleted before it started is now archived anew when it prints, on a printer that archives its prints. Before, the print ran with no archive, no start notification and no usage tracking. - A timelapse deleted while it was being converted to MP4, or replaced by a reprint's in the meantime, no longer comes back when the conversion finishes. - **Print dialog fixes** — - **Print Anyway** in a print to several printers now only waves through the printers whose filament warning it answered. Before, it went onto every selected printer, so one that was never checked also skipped the scheduler's filament checks. - A job with printer alternatives whose plate dropdown was left untouched showed **Plate 1** but was queued with no plate, so it asked for every plate's filaments and the whole file's print time and could find no printer. - On H2D and H2C, the nozzle shown for each filament now comes from the chosen plate, not from all plates of the file, which could match trays on the wrong nozzle. - A sliced 3MF named without `.gcode` (a plate exported from Bambu Studio) can now be dropped on a printer card or picked in **Upload for Print**. An unsliced 3MF is refused with a message saying so. - **Orders and groupings show as finished however their last job ended** — An order or grouping was only re-checked when a print completed, an item was deleted or one was edited. A grouping whose last job was cancelled or stopped, or whose jobs were cancelled because their library file was deleted, an order whose last run was closed by the stuck-print cleanup, and one whose last open job failed before it reached the printer or was deleted, stayed **active** until the next restart. They are now re-checked on every way a job ends. - **A job cancelled while it is being sent stays cancelled** — Cancelling a queued job during its upload (the edit dialog even asks you to cancel a job that is being sent) was overwritten when the upload then failed: the job turned into a failure, a failure notification went out, and the next job that needs the previous print to have succeeded was skipped. A failure is now only recorded for a job that is still waiting or printing, and the upload toast of a cancelled job closes instead of spinning. Likewise, a job stopped just as its printer turned busy is no longer put back in the queue, and a stopped job whose nozzle rack pick no longer fit no longer leaves its file on the printer. - **"Only if the previous print succeeded" looks at the right printer** — For a job queued for any printer of a model, the check ran before a printer was chosen, against jobs with no printer, so it nearly always let the job go, even onto a printer whose last print had failed; when it did trigger, the job was skipped for good. It now checks the printer it is about to use, passes over one whose last print failed and takes another of the model, and waits if every free one failed last. The queue then shows a **Resume after failure** banner for that model, which clears the gate on its printers, and the "job waiting" notification also goes out when a job that waited for a busy printer comes to wait on such a failure. On PostgreSQL, a job cancelled without an end time (cancelling an order, a pipeline or a kept order run) also counted as the latest print and hid a real failure before it; such cancels now record their end time, and the check no longer ranks a missing one first. - **A queued job moved to another printer no longer keeps the old printer's settings** — A job's AMS tray mapping, **Print Anyway** and nozzle choice are worked out for one printer. Moving jobs to another printer (bulk edit, or editing one job) kept them, so a job could print from the slot that held the right material on the old printer but another colour on the new one, and a **Print Anyway** given for the old printer skipped the new printer's filament checks. **Dispatch remaining** of an order also pinned every copy of a job queued for any printer of a model to the printer its latest run happened to land on, with that printer's settings, so the other printers of that model stayed idle. These settings now stay with the old printer and are worked out again for the new one; plate, AMS use, filament overrides and print options are kept. Bulk edit leaves jobs with printer alternatives alone, as editing one does, and a job it moves to a printer no longer also waits for a model. - **Pages work for users without the settings permission** — Users in a group without **settings:read** could not read the install settings, so pages that need a few of them fell back to defaults. With billing on, the print dialog hid the cost centre picker and the print was then refused for lacking one. The print dialog also lost its print option and stagger defaults, currency and filament preference. The queue showed the wrong order with shortest job first on, the File Manager lost **Slice**, filament warnings an administrator had turned off still appeared, adding an unknown spool from its tag put it in the built-in inventory even in Spoolman mode, and the theme, currency and forecast lead time fell back to defaults. These settings now reach every signed-in user, without the credentials **settings:read** also shows; G-code snippets are only reported as set or not. - **SpoolBuddy writes the Spoolman spool to the tag in Spoolman mode** — Built-in spools are kept after switching to Spoolman. When one of them had the same id as the Spoolman spool being written, SpoolBuddy wrote the built-in spool's material, colour and weight to the tag and linked the tag to that hidden spool, while reporting success. The Spoolman spool never got the tag, so scanning it later found nothing. Writing a tag now uses only the inventory in use. - **Budget held by prints that ended is released** — Before a print without a user (authentication off, or its user deleted while it printed) was charged to its cost centre, it kept its budget reservation for good, so the cost centre showed that much less budget left. At the next start, reservations of prints that have finished or were removed from the queue are released. Only the reserved amount is freed; charges already booked don't change. - **A pipeline run remembers how each copy ended** — A copy a slicer pipeline sent to the queue only showed its outcome while its queue entry existed. Once the entry was deleted (from the queue page, with its archive, or with its user) the run could no longer tell a printed copy from one that never ran: it stayed **dispatching** forever, and **Retry failed** could not tell what to retry. Each copy now keeps its final state (completed, failed or cancelled) when its queue entry ends, and removing a copy that hadn't printed yet counts as cancelled. Copies whose entry was already gone before this update show as **unknown**: never retried, and counted as finished, so the run completes. - **Deleting a spool keeps its usage history, and a new spool no longer takes over a deleted one's** — On PostgreSQL, deleting a spool deleted its usage history, so **Recalculate costs** replaced the real spool cost of its prints with an estimate. On SQLite the history stayed, but the next spool created could get the deleted spool's number and then showed its prints and consumption as its own. The usage history now stays on both databases without a spool, the prints keep their spool cost, and spool numbers are never given out again on SQLite. A Git backup restores such history as well. Supplier and material statistics leave out usage of deleted spools, as there is no spool left to count it for. - **Archives a spool was used for can be deleted on PostgreSQL** — With PostgreSQL, deleting an archive for good (deleting it with **purge from statistics**, the automatic archive purge with that option, or deleting a printer together with its archives) failed with a server error when a spool from the inventory had been used for that print. The spool's usage record now stays and only loses its link to the deleted archive, on both databases; on SQLite, usage records still pointing at an archive deleted earlier are cleared of that link at the next start. **Recalculate costs** no longer charges usage records without an archive link to archives by their print name: that added the spool cost of a deleted print, of a print made with archiving off, or of every earlier run to each archive with the same name. Archives without linked usage get the estimate from the filament price instead, as they already did when no such record existed (usage recorded before 0.2.1, or with archiving off). - **An id of 0 no longer slips past the checks when queueing, batching or creating a project** — Adding to the queue with an archive id of `0` next to a library file, creating a batch with an archive or library file id of `0`, and creating a project with parent `0` skipped the check that the archive, file or project exists and saved `0` as its id: a server error on PostgreSQL, a link to nothing on SQLite. `0` now means "none" there, as it already did when editing a project. Creating a batch also checks that its archive or library file exists, and refuses an unknown one. - **Archiving a virtual-printer upload into a project that does not exist is refused up front** — The project chosen when archiving a pending upload was saved without checking it. On PostgreSQL an unknown project failed with a server error after the file had already been archived; on SQLite the archive was saved with a broken project link. The project is now checked first and an unknown one refused with "Project not found", before anything is archived. - **A project's bill of materials no longer shows archives from printers outside the user's groups** — A BOM line can be linked to an archive through the API, and the BOM answered with that archive's name whichever printer it came from. A member of a group limited to some printers could read the name of any archive by linking a line to it, and everyone reading the project saw the names of archives from printers they have no access to. A link to an archive that does not exist was saved too (a server error on PostgreSQL). A BOM line now links only to an archive the user can see, and shows a linked archive only to users who can see it. - **Adding archives or queue jobs to a project keeps to the user's own printers and prints** — Adding archives to a project (**Assign to project**), removing them, and adding queue jobs took any archive or job by its id. A member of a group limited to some printers could link another team's archives and jobs to a project or unlink them, and the reply's count told them which hidden archives exist; any project editor could also relink other users' prints. These now change only the archives and jobs the user could edit directly: on their printers, and only their own unless they may edit everyone's (Operators: their own). Others are skipped like missing ones, and **Assign to project** now reports how many were actually added. An API key with project access still needs no archive or queue access for this, but changes only the archives and jobs of its printers that its owner could change. - **Editing an archive can no longer move it to a printer outside the user's groups** — A member of a group limited to some printers could move an archive of one of their printers to any other printer through the archive edit, into the history of a team they have no access to, although uploading an archive to such a printer was already refused. Any edit could also link an archive to a printer or project that does not exist (a server error on PostgreSQL, a broken link on SQLite). Such an edit is now refused with "Printer not found" or "Project not found". Moving an archive between the user's own printers, and removing its printer or project, work as before. - **A smart plug or virtual printer can no longer be linked to printer 0** — Adding or editing a smart plug and adding a virtual printer skipped the "Printer not found" check when the printer id was `0`. On PostgreSQL the save then failed with a server error; on SQLite the plug or virtual printer was saved linked to a printer that does not exist. Such a request is now refused with "Printer not found", as for any other unknown printer. - **No more firmware-check errors in the log while the internet is down** — Without an internet connection, every firmware check of every printer logged a full error for each Bambu Lab page it tried (measured: 12 errors and 9 warnings for three printers checked three times). The outage is now logged once per Bambu Lab site as a warning, the checks keep retrying quietly, and a line in the log says when the site is reachable again. - **Bambu Cloud being unreachable is reported as such** — When Bambu Cloud failed (no internet, a blocked or failing connection, an error or an error page such as a Cloudflare challenge), the cloud preset, device and firmware views answered with a server error (500), and pasting an access token answered as if the token were wrong (401). Signing in or entering the email code without a connection to Bambu Cloud also answered 401, as if the password or code were wrong. These now answer 502 (Bambu Cloud unreachable) with the cause, as the preset lookups already did; a pasted token counts as invalid only when Bambu Cloud itself refuses it. An expired session is answered as such (401, sign in again) instead of with a server error. - **Editing a smart plug can no longer save a plug that cannot be read back** — Adding a plug checks its values: a Tasmota plug needs an IPv4 address, a Home Assistant plug an entity of the right kind, an MQTT plug a topic, a REST plug an ON or OFF URL, and the name must not be empty. Editing one did not, so clearing such a field, switching the type without filling in the new one, or typing a hostname as a Tasmota address saved a plug that the smart plug list, the plug itself and the edit all failed on with a server error until it was fixed in the database. Such an edit is now refused with the same message as when adding a plug, and the plug stays as it was. Switching a plug to Tasmota on a printer that already has a Tasmota plug is refused as well (adding one already was); before, it gave the printer a second one, and adding a plug to that printer then failed with a server error. - **Creating or importing a project gives it a priority and status the project editor accepts** — Importing a project from a file without a priority gave it the priority `0`: on PostgreSQL the import failed with a server error, on SQLite the project got a priority the project editor itself refuses. Creating a project through the API stored any priority, and importing one any status, the same way (a value longer than 20 characters failed with a server error on PostgreSQL). An imported project now keeps a valid priority (low, normal, high or urgent) and status (active, completed or archived) and gets **normal** and **active** otherwise, from a file and from the JSON import alike. Creating a project with an unknown priority is refused with "Invalid priority", as editing one already was. Such projects could not be edited at all (every save, even of the name alone, was refused as an invalid priority or status); existing ones get **normal** and **active** at the next start. Restoring a backup from such an older SQLite install onto PostgreSQL no longer fails on them either: they get the same values while restoring. - **The "Password Reset" email template is used again** — Since password resets send a link instead of a new password, the reset email (self-service and admin) was always the built-in one: editing the "Password Reset" template under Notifications changed nothing, and its default text still promised a new password. The reset email is now built from that template, and Bambuddy adds the reset link at the end of it (and as a button); the template itself cannot place or contain the link. `{password}` and `{login_url}` are gone from this template. The default text is updated on upgrade unless you edited it; if you did, reword it for a link instead of a new password. - **A cost centre is charged for a print whose user was deleted while it ran** — When a user was deleted during their print, the print was never charged: the cost centre's ledger missed its cost, and its budget reservation stayed active for good, holding back that much of the budget. The print is now charged to its cost centre, also to the deleted user's own private one, which their personal prints go through, and its reservation is settled. A queue print is charged under the user who queued it, never under the owner of the archive it reprints. Ledger entries without a user now read "No user" instead of "Deleted user": with login turned off, prints on a cost centre are now charged too and never had a user. Deleting a user together with their items now also releases the budget their queued prints still held; a print of theirs still running is kept until it ends (without a user) and billed to its cost centre like any other. Only a print with no cost centre at all (started while billing was off) has nobody left to charge. - **Deleting a printer no longer deletes an archive of a print starting at that moment (PostgreSQL)** — If a queue or reprint print started on the printer while it was being deleted, the delete could take the print's archive with it, even with "keep archives" and even when the archive belonged to another printer or a virtual printer queue. The delete now locks the printer while it runs, so such a print start waits and is turned away, and its archive stays as it was; this is logged as a print start for a deleted printer, not as an error, and sends no start notification. An archive that could not be written no longer leaves its copied 3MF behind in the archive folder. - **A printer deleted while its print was starting no longer gets an archive or tracking** — When a print starts, Bambuddy downloads its 3MF from the printer, which can take minutes. A printer deleted during that download still got an archive afterwards: on SQLite one that showed "printing" forever, since the deleted printer would never report the end, along with a layer timelapse and tracking for it; on PostgreSQL the archive was refused and an error logged. Bambuddy now checks that the printer still exists once the download is done, and again once the archive is written; a queue or reprint print starting just as its printer is deleted is no longer attached to that printer either. Archives the delete was told to keep stay as they are. A queue print dispatched to a printer deleted during its upload no longer records its owner for that printer either. - **Closing one camera view no longer cuts off the others** — With an external RTSP or USB camera, closing one view of a printer (a Cam Wall tile turning to snapshot, the Camera page, the camera window's refresh) stopped that printer's camera stream for every other open view too, which then froze until it reconnected. Each view now names itself on its stream and its stop: the stop ends that view's own stream (at once, as before, so a USB camera is released when its view closes) and any stream that has stopped sending pictures, and leaves the other views' streams running. - **Closing or minimizing a camera view now ends its stream** — The camera window on the Printers page left its stream open when minimized (one more open connection per minimize and expand), and both it and the Camera page closed only the first picture they had shown: after a reconnect or a restart, closing them left the current stream running. An open stream keeps the printer's camera connection busy, keeps the chamber light on when "Turn on chamber light for camera" is on for that printer, and made the "stop camera" request on closing do nothing. Every picture a camera view drops now ends its stream. A reconnect also no longer counts one extra failed attempt toward the reconnect limit. - **Cam Wall tiles no longer freeze on their last frame** — When the server ended a live stream (the camera dropped, or a brief database hiccup during the stream's 30-second access check), browsers report nothing, so a Cam Wall tile kept showing its last frame until the page was reloaded. Live tiles now reconnect their stream every minute while the page is visible, so a camera that comes back shows a picture again within a minute. If the camera stays down, the tile shows "No signal" (a printer's built-in camera keeps its last picture until Bambuddy stops retrying it). A tile that leaves live mode (scrolled away, paused) now closes its stream too, instead of leaving the connection open. The streaming overlay, which already renewed its camera every minute, now closes the old connection first: a browser source left running for hours piled up one open camera connection per minute. A single failed access check no longer ends a stream either: it is checked again after 5 seconds, and only a second failure in a row ends it. - **SQLite no longer gives a deleted user's, API key's, printer's or archive's number to a new one** — SQLite reuses the highest freed id, so whatever still referred to a deleted user, API key, printer, print archive or camera token could end up referring to the next one created. Earlier fixes cleaned up after each such case; these tables now never reuse an id at all. Existing SQLite databases are converted once at start (the rows, columns and indexes are kept), and ids still referred to (a deleted printer's print history, for example) are skipped. PostgreSQL never reused ids itself; a SQLite backup restored on PostgreSQL (including one restored before this release), or a PostgreSQL backup restored on SQLite, now keeps those ids unused too. - **Taking away live updates or camera access through a group now ends them** — Only creating a dashboard's live connection or a camera link checked the permission for it. When an admin removed that permission from a group, removed a user from the group that granted it, or turned off an API key's status access, a dashboard already open kept receiving live updates and could reconnect for up to an hour, and an open camera view kept running, its link opened new views and snapshots, and a Cam Wall or Home Assistant camera token kept working until it expired. Those connections and views are now closed and the links refused once the permission is gone; the dashboard falls back to refreshing on its own as it does for users without live updates. - **An open camera view stops once the viewer may no longer watch** — A live camera stream checked its link once, when it started, and then ran for as long as the tab stayed open. After a password change or reset, removing someone's two-factor authentication, deactivating a user, a backup restore, disabling or expiring an API key, revoking a Cam Wall or overlay token, a group change that takes the printer away, or turning authentication on, a view already open kept showing live video. Streams now check every 30 seconds that whoever opened them may still watch that printer, and end if not. A view that is still allowed keeps running, including past the hour its link is valid for. - **A tab whose live connection was closed by the server now recovers instead of freezing** — When the server closes the live-update connection because of sign-in (your session ended, authentication was just turned on, or a temporary check failed), the page used to stop updating and stay on screen until reloaded. It now re-checks your sign-in: a tab whose session ended goes to the login page, and one that is still signed in reconnects. While its connection keeps being refused or can't be made it waits longer between attempts, up to five minutes when the server closes an accepted connection again and up to 30 seconds otherwise, instead of retrying every three seconds. - **Signing a user out everywhere now also closes their open dashboards** — A dashboard's live connection is checked only when it connects. Changing your password, a password reset link, an admin removing someone's two-factor authentication, deactivating a user and restoring a backup all make earlier sign-ins invalid, but a dashboard already open on the account, possibly someone else's, kept receiving live updates until it disconnected, and the link it connected with stayed valid for an hour to connect again. Those live dashboard connections are now closed and their links removed; your own tab reconnects after signing in again. Turning authentication on likewise closes the dashboards opened while it was off, which kept every printer, and disabling an API key, or one expiring, closes the dashboards it opened. - **A deleted API key's open connections and tokens no longer pass to the next key** — SQLite gives a deleted key's id to the next key created. Live dashboards opened with the key stayed connected, and the camera and WebSocket links it created stayed valid for up to an hour; both were tied to the key by id and took the new key's printers. Deleting a key, or a user with keys, now closes those connections and removes the links, and links of keys deleted before are removed at start. - **A printer added after a delete no longer takes over the deleted printer's print, notifications, camera and plate references** — SQLite gives a deleted printer's id to the next printer created, and Bambuddy kept much of this under that id until the next restart. - **Prints and billing:** the new printer took over the owner of a print still running when the old one was deleted. Its prints were treated as started from Bambuddy, so the kill switch let them run, and its first finished print was credited to that user. A job sent to the old printer that hadn't started yet could do the same for a print with the same file name. A new printer that was already printing when added could also take over the old print's filament tracking, charging its filament to the deleted printer's spools. - **Gates and notifications:** the plate-clear prompt could block the queue on the new printer. Pending "bed cooled" and offline notifications fired under its name, and the first print's milestone notifications could be skipped. - **First connect:** the steps that run when a printer connects, such as reading its K-profiles, were skipped. - **Camera and detection:** a viewer could get the old printer's live picture for a few seconds, a layer timelapse kept recording from the old camera, and Obico failure detection showed the old verdict. - **Plate detection:** the old printer's reference pictures of its empty plate stayed, so the new printer showed as calibrated, showed those pictures and compared its plate against them. They are now removed with the printer, and when a printer is added under an id that still has some from an earlier delete. - **SQLite: deleting archives, library files, queue jobs, batches or projects no longer leaves references to them behind** — SQLite doesn't apply the database's "when this row is deleted, clear or delete what points at it" rules, and it gives the highest freed id to the next row created. So a queue job could end up printing a different archive or library file than the one it was made for, a new project could show a deleted project's files and folders, and archives kept the id of a deleted library file (a new file then inherited them in project progress). Every delete of these now applies the rules the way PostgreSQL does, and every start repairs what earlier deletes left behind: queue jobs of a deleted archive are removed, as deleting an archive already does, while those of a library file that is gone are cancelled or kept as finished, as a library delete does. Deleting a user now also clears their id in the print log, so those runs show under "no user". Deleting a printer together with its archives, and the archive purge, now leave an archive alone while one of its queue jobs is printing (the printer delete answers that it has to wait), as deleting a single archive already did. Archives stored before archive search existed are now added to the search index once; until then, editing such an archive could fail with "database disk image is malformed". PostgreSQL installs aren't affected, apart from the printing check. - **API: `null` for a field that can't be empty no longer causes a server error** — On most PATCH/PUT routes, `null` for a field leaves it unchanged. About a dozen routes (printers, smart plugs, queue items and bulk edits, archives, filaments, spools, notification providers, GitHub backup, Home Assistant sensors, maintenance, external links) wrote it to the database instead and failed with a server error when the field can't be empty, such as a name or a switch. They now leave it unchanged too, and the rest of the request is applied. `null` for a field that may be empty, such as a printer's location, still clears it. The web interface never sent such values; this affects API clients and scripts. - **Webhook routes answer a database problem with 503, not 500** — The `/webhook/...` routes checked API keys with their own copy of the check, which missed earlier fixes. A database error while checking the key, a key too long to hash or a damaged stored key came back as a 500. They now use the same check as every other route: 503 when the key can't be checked right now, 401 when it isn't valid. An expired key now reads "Invalid API key" there too, like everywhere else. - **The MQTT relay publishes what the wiki says it does** — Three topics were missing or wrong. `smart_plugs/energy` was never sent: with the relay connected, each enabled plug's power, today's energy and total energy now go out once a minute (without the relay no plug is read for this, and an MQTT plug that stopped reporting is left out). `smart_plugs/on` and `/off` only came from switching a plug by hand; auto-on, auto-off, schedules, the queue powering a printer on and failure detection switching a plug off now publish them too. `maintenance/alert` always said 0 for the current value and the threshold; it now has the hours (or days) since the item was last done, its interval and a `unit` field. - **PostgreSQL databases from older versions get the links a new install has** — A database created before v0.2.4.1 (or before v1.2.5.2 for the last one) was missing three links: from a print log entry to its archive and to the user who started it, and from an archive to the library file it was printed from. Deleting an archive, a user or a library file left their ID behind instead of clearing it, so such entries didn't show up under "no user", for example. The links are added on the next start, and IDs of things already deleted are cleared. A print log entry without an archive also stays that way now: every start used to attach it to the newest archive with the same name on the same printer, which could be someone else's. 51 columns, mostly settings flags, also accepted an empty value there or had a default only those databases had. They now match a new install, and an empty value gets the column's default, with two exceptions so nothing gains access: an empty API key permission becomes "off", and an OIDC provider without an email claim, which so far linked no accounts, gets auto-linking turned off instead of starting to link by email. Apart from that, SQLite installs are unchanged. - **Spoolman: colour, material and cost from the spool weren't applied on older PostgreSQL installs** — On a PostgreSQL database created before v0.2.4.8, the start snapshot of the AMS levels was stored as text instead of JSON and read back as text. Reporting usage to Spoolman then failed after charging the spools from the 3MF, with "Spoolman usage reporting failed" in the log. Prints without a 3MF charged nothing, and no archive got the colour, material and cost of its Spoolman spools. The column is converted on the next start. Two number columns also only had single precision on PostgreSQL databases older than them and are widened, keeping the values as entered: a spool's "Reset usage" baseline (databases created before v0.2.4.2) and a REST smart plug's energy total multiplier (before v1.2.5). SQLite installs aren't affected. - **Every install now gets the dependency versions that were tested** — Bambuddy's Python dependencies only had minimum versions, so each Docker build, install and update took whatever was newest that day: a fresh install could run library versions nobody had tested Bambuddy with (SQLAlchemy 2.1 arrived that way). They are now pinned in `requirements.lock`, which Docker, the installers, the update scripts and the tests all use, and newer versions are taken on deliberately, after testing. Nothing changes for an install today: the pins are the versions a fresh install gets now. A native install moves to the pinned versions with the update after the one that brings this change, since an update still runs the previous version of the update script. - **Native installs need Python 3.11 or newer** — Since v0.2.4.8 Bambuddy hasn't started on Python 3.10, while the installer, README and wiki still said 3.10 was enough. The requirement now says 3.11. The installer uses the system's `python3` when it is new enough and otherwise looks for a newer Python next to it: on Ubuntu 22.04, whose `python3` is 3.10, it installs and uses the `python3.11` package. An environment left from an older Python is rebuilt. The update scripts check the install's Python first and, if it is too old, stop before changing anything and link to the steps for moving it to 3.11. Docker and the Windows installer ship Python 3.13 and are not affected. Wiki updated. - **The webhook endpoints listed in Settings didn't exist** — **Settings → API Keys → Webhook Endpoints** listed endpoints for the status of all printers, pausing and resuming that Bambuddy doesn't have, and two others under the wrong path. It now lists the real ones: add to the queue, start the next queued job, stop, cancel, printer status and queue status. Wiki updated. - **A valid API key could be refused as invalid during a brief database problem** — When checking an API key hit a database error (a busy database, or saving the key's last-used time while it was locked), the request was answered "Invalid API key", so integrations such as Home Assistant or scripts were told their key was wrong. It now answers "Authentication service temporarily unavailable" (503), which a client can retry, and the error is logged. A wrong or expired key is refused as before. - **Errors were reported as "Authentication service temporarily unavailable" with login switched off** — Without authentication, any unexpected error in Bambuddy answered 503 "Authentication service temporarily unavailable", and the log blamed the authentication check, which sent troubleshooting the wrong way. Such errors are now reported as the internal error they are (500), and the error with its details now lands in `bambuddy.log` (and so in support packages), whether login is on or off. The protection that refuses requests when Bambuddy can't tell whether login is on stays as it was. - **With login on, a brief database hiccup no longer signs you out:** while checking a sign-in, a database error answered "Invalid token", and the web interface took that as final, signed the user out and forgot the sign-in. It now answers "Authentication service temporarily unavailable", which the interface retries, and the error is logged. - **A print started outside Bambuddy could be reported to the owner of the previous queue job** — On a printer with **Auto-archive completed prints** switched off (in the printer's settings), a print Bambuddy didn't start (for example one started on the printer itself) has no archive. When it finished, failed or was stopped, the notification looked for any queue job this printer had finished in the last five minutes and treated the print as that job: its owner got the "your print is done" email for a print that wasn't theirs. It now uses only the queue job the print itself belongs to, so such a print isn't sent to anyone's personal email. - **A queue job whose archive couldn't be found** at the end, when it finishes or fails, is still reported to its owner, and its duration (`{duration}`) is how long it actually ran instead of "Unknown". A job stopped from the queue page in that situation no longer sends its owner the "stopped" email. - **Smart plug power alerts were never sent** — A plug's **Power Alerts** (alert if the power is above or below a value) never reached any notification provider, and each time one was due, that request for the plug's status on the printer card failed. They also were only checked while someone had a printer page open. - **Now checked in the background,** about once a minute, whether or not Bambuddy is open. - **One alert per crossing:** you're told when the power leaves the range, and again only after it was back inside it, so a printer idling below a low value is reported once rather than every five minutes. The low value only counts while the plug reports itself switched on, so turning a printer off raises no alert (a plug that can't report its switch state gets no low alerts). Alerts go out as the **Printer Error** event ("Printer Error: Power High" / "Power Low"). Wiki updated. - **A project's timeline stayed empty while one of its jobs was queued or printing** — The **Timeline** on a project's page failed to load as soon as the project had a pending or printing job in the queue, so it showed no events at all, not even the finished prints. It loads again, and queued and running jobs show under their print name (or file name; a job for any of several printer models under its first file). - **Stopping or cancelling a print through the API failed with an error** — `POST /api/v1/webhook/printer/{id}/cancel` answered with an error and left the print running. `POST /api/v1/webhook/printer/{id}/stop` stopped the print but still answered with an error. This affected Home Assistant automations and scripts that use an API key; the Stop button in Bambuddy was not affected. - **Now both stop the print and answer normally.** If the printer drops off before the command can be sent, they answer "Printer not connected" (503). - **A print stopped this way now counts as stopped, not failed,** as with the Stop button: the "print stopped" notification goes out instead of "print failed", and the print is no longer mislabelled with a printer error such as a layer shift. - **`…/stop` now stops a paused print too,** as `…/cancel` and the Stop button on the printer card do; it used to answer that no print was in progress. - **"Stop Printing" in the HMS error dialog** now also counts as stopped rather than failed. - **Starting the next plate of a Send All deleted the previous plate's timelapse (#3275, reported by @dovmesiz)** — All plates sent together with **Send All** share one archive. When a print starts on an archive that already has a timelapse, Bambuddy clears it so the new run gets its own video and completion photo. It also deleted the old video file, so starting plate 2 deleted plate 1's. With **delete from printer after archiving** on, that was the only copy. The video is now deleted only when the same plate prints again. Another plate's video, or one attached by hand or before this release, stays in the archive folder and is removed with the archive. The card still shows the latest plate's video. An external-camera timelapse no longer overwrites a kept video of the same name. - **A queued print still running 2 hours after it was sent lost what Bambuddy stored for it at dispatch:** its plate, AMS mapping and cost centre, which the completion uses (for example for the per-plate totals in the notification). A clean-up of unused print registrations removed them mid-print. They are now kept until the print completes. - **A PP spool without a slicer preset was sent to the AMS slot with no filament ID (#3273, reported by @sgiffhorn)** — When a spool has no preset of its own, Bambuddy fills in Bambu's generic preset for its material. The table used for spool assignment had no entry for PP, PE, PCTG, PLA Silk or PLA High Speed, so those slots got an empty filament ID, while **Configure AMS Slot** used a separate, longer table. The backend now has one table with all five, matching the one the frontend already used. A slot already set to Generic PLA Silk or PLA High Speed still keeps it when a PLA spool without a preset is assigned, as before. - **PVA, HIPS and Bambu support filaments got the wrong setting ID:** their filament IDs start with `GFS` (Generic PVA is `GFS99`), which Bambuddy took for a setting ID. It sent `GFS99` instead of `GFSS99` and read it back as `GF99`. - **Every start filled the PostgreSQL log with about 320 errors** — On PostgreSQL, the startup migrations re-ran every column, index and constraint they had already added, and the database logged each of those as an `ERROR` with its statement, about 640 lines per start, before Bambuddy quietly skipped it. Nothing was wrong, but the noise buried real errors and alarmed anyone reading the log, on the appliance as much as on Docker with PostgreSQL. On PostgreSQL these statements now ask the database to skip what is already there (`IF NOT EXISTS`, or a check of the catalog for constraints and renames), so a normal start logs no errors. Columns, renames and constraints that are actually missing are still added, and SQLite is unchanged. PostgreSQL 13 or newer is required, and the wiki now says so: Bambuddy has never started on PostgreSQL 12 or older, whose startup migrations fail on a column type those versions reject. - **A large timelapse could not be attached when it took more than 5 minutes to download (#3272, reported by @dovmesiz)** — Scanning for a timelapse, picking one by hand and the automatic attach after a print all gave the download a flat 300 seconds. A 75 MB video that took 358 seconds over a healthy P1S link failed with "Failed to download timelapse", and each retry started the file again from scratch. The time allowed now follows the file's size, so a slow transfer completes; a link that stops sending still fails after the FTP timeout as before. - **A printing queue job showed the slot AMS Filament Backup had switched away from as empty** — When a spool ran out mid-print and the printer carried on from the backup spool, the job's card in the queue still named the original slot, now marked **Empty**, as if the print had a problem. The card now shows the backup spool the printer is using. It follows the printer's own record of the trays the print has drawn from, including a backup that ran out in turn. A switch between two slots the job uses is a normal colour change and is not taken for a backup. Queued jobs still warn about an empty slot as before. - **Slice dialog hid renamed printer profiles while "Only printers that are online" was on (#3172, reported by @gregspatrick)** — The filter read the printer model from the profile's name, so a copy saved as `Bambu Lab P1S 0.4 nozzle - Copy` read as an unknown model and was hidden even with a P1S online, until **Show all** was picked. It now reads the model from the profile the copy was saved from, as the loaded-spools filter already did. - **Printer cards never showed the last print, nor the Good / Reject prompt for it** — Each card asked for its printer's archives with the arguments in the wrong order, so it requested none and always got an empty list. The **Last:** line on an idle card and the outcome prompt from #1898 never appeared there. - **One request for every card:** the cards now share a single request that returns the archive each printer printed last (a reprint of an older archive counts), instead of one per card. On a farm with 59 printers that was 59 requests on every page load and after every finished print, each scanning the whole archive table for duplicates. - **The File Manager and Archives page loaded every thumbnail at once** — With a few hundred files every thumbnail was requested on page load, and the queue of requests slowed down everything else on the server for several seconds. Thumbnails now load as they scroll into view. - **Statistics counted a multi-material print written from spools as one material (#3262, reported by @ojimpo)** — A print's materials are stored comma-joined, as `PLA, PLA-S` from the sliced file but as `PLA Basic,PLA` once they are taken from the spools that fed it. The Statistics page split on comma plus space only, so those prints showed as one `PLA Basic,PLA` row in the material weight, print count, time and success-rate charts, and the Archives material filter offered the joined string as a material of its own and missed those prints when filtering by `PLA`. Both now split on the comma alone, as the statistics API already did, so existing archives are counted under each of their materials. - **A running print's cost used the default rate instead of its spools, then changed without notice at completion (#3261, reported by @ojimpo)** — The archive was priced at creation from the Filament catalogue or **Default Filament Cost**, and the spools that fed the print only priced it once it finished, in both Spoolman mode and the built-in inventory. Anyone noting the cost while a long print ran recorded a placeholder. A print is now priced from its spools when it starts, from the sliced file's per-slot estimate and the spool in each mapped tray, with the same default-rate cover for grams no spool prices; a reprint still keeps its first run's cost. The archive card marks a running print's cost with `~` and a tooltip, since the final figure comes from what was actually used. A print whose slots can't be matched to trays at the start keeps the placeholder until it finishes. - **AMS Filament Backup showed "?" on the P1S, P1P, A1 and A1 Mini (#3259, reported by @HopNotch)** — These printers never send the `cfg` field Bambuddy read the setting from; they carry it in `home_flag` bit 10, where Bambu Studio reads it for every printer. Bambuddy now falls back to that bit, so the badge shows the printer's real setting and the prefer-lowest-remaining gate follows it. Switching the setting in the slicer now shows up without a Force Refresh. Before 1.2.5.6 the badge seemed to work on these printers only because it read Bambuddy's own print command echoed back, which always said off (#3040). - **Slicing failed with a printer preset saved under a name of its own (#3250, reported by @bobobo1618)** — A printer preset copied from a stock one and renamed, such as "Bambu Lab H2D 0.4 nozzle - Apply Skew Calibration", failed every slice with "The selected printer is not compatible with the process preset in the 3mf", even with a stock process the stock printer slices fine. - **Why:** the slicer checks a process against the stock preset a printer preset was saved from. The slicer sidecar handed it the renamed preset as a stock preset in its own right, and no process lists that name. It now names the stock preset the printer was saved from, while still slicing with the renamed preset's own settings, start G-code included. A process or filament preset that lists the renamed printer itself keeps fitting it, as it does in the slicer. Needs the updated slicer sidecar image (OrcaSlicer and Bambu Studio). - **The Slice dialog** read the printer model out of the preset's name, which a name of your own may not contain, so it misjudged which process and filament presets fit. It now also checks against the stock preset the printer was saved from, for imported and OrcaSlicer Cloud printer presets. - **A print started from the printer's screen took about six seconds longer to archive (#3009, reported by @bdwilson)** — A job started on the printer's own screen reports its file's full name, extension included (`Part.3mf`), where a job Bambuddy sends reports the bare name. Bambuddy added the extensions to it anyway and asked the printer for `Part.3mf.gcode.3mf` and `Part.3mf.3mf` first, six connections that could not succeed, before trying the real file. The check that reattaches a running print after a Bambuddy restart compared against the same doubled name, so it could not find such a print. When the printer reports the same full name as both the job and its file, that name is now taken as the file itself: it is the only name tried, and the restart check also matches that exact file. Prints Bambuddy dispatches are looked up as before. - **A queue job stayed "printing" for five minutes when its file name ended in a space (#3241, reported by @Thomansky)** — A file saved as `Part .gcode.3mf` is sent to the printer as `Part `, and the printer reports it back as `Part_`, turning that space into an underscore like every other. Bambuddy dropped the space from its own side before comparing, so the names differed, the finish was ignored as belonging to another print, and the job showed "printing" until the stale-job cleanup closed it about five minutes later, holding up the printer's next job. The finish photo's plate restore skipped these prints for the same reason. A space at the start of the name had the same effect. Both are now matched. - **The finish photo never raised the plate back into view (#3240, reported by @Thomansky)** — Since 1.2.5.2, Bambuddy is meant to raise the build plate back to just above the finished print before the finish photo, then lower it again, because the printer's end G-code drops the plate about 100 mm. It never did, on any printer: the lookup for the print's height failed every time and logged nothing above debug level. It read the archived file from a setting that does not exist, and it matched the archive by exact name, while the printer reports a name with its spaces turned into underscores. Both are fixed, so the plate now really moves after a print when **Restore plate for finish photo** is on, which it is by default. Turn it off under **Settings** > **General** to keep the photo where the plate stops. Because the plate now really moves, the print's height is taken only from the archive Bambuddy linked to that print when it started, and from the plate that was sent, never from another archive that merely has the same name: every plate of a multi-plate file shares one name, and support bundles showed the old lookup picking another plate's archive, stopped only by its layer count. That archive must also match the print's name exactly, never part of it or a shortened version; its layer count must agree with the printer's; and the plate stays put when another job is queued for the printer or the print was not linked to an archive. - **An "Any model" job was held for too little filament on one printer while another idle printer of that model had plenty (#3137, reported by @RambachTJ)** — The queue gave a job queued for **Any H2D Pro** (or any model) to the first idle printer with the right filament type, and only then checked whether its spools held enough. When they did not, the job waited on that printer for a manual start, even though another idle printer of the same model could run it; the only way out was to move it by hand. The queue now passes over a printer that would run short and gives the job to one that has enough. If every idle printer would run short, the job still waits on the first, where **Print Anyway** works as before, and moves by itself once another idle printer of its model has enough filament, on record in Inventory or Spoolman. - **Shutting Bambuddy down logged an error with a traceback when nothing was wrong (#3243, reported by @Thomansky)** — Stopping Bambuddy cancels the printer updates still waiting to be handled, and each cancelled one was logged as `ERROR ... Exception in scheduled callback` with a traceback, as the last line before the restart. In a log or a support bundle it looked like a crash. A cancelled update now ends quietly; one that actually fails is still logged as an error. - **H2D, H2D Pro and H2C with several AMS on one extruder marked the wrong tray as loaded on every filament change (#3242, reported by @Thomansky)** — These printers report the loading tray as a slot number (0-3) without saying which AMS it is in, and the report that says which AMS is read a moment later. In that gap Bambuddy assumed AMS 0: during a print feeding from AMS 2 slot 1, AMS 0 slot 1 showed as loaded for about a second, and that tray went into the list of tray changes that splits a print's filament between spools. Support packages show this on every filament change. - **Now:** while a print runs, the slot is matched against the trays the print uses, taken from the printer's own mapping. If that doesn't settle it, as in a multi-colour print with the same slot in two AMS, the tray already loaded stays marked until the printer says which one is feeding. - **Usage:** no case was found where a spool was actually charged for the wrong tray, because the wrong tray was always replaced within the same layer. It no longer gets into the list at all. - **Idle printers:** the printer keeps reporting the last print's mapping after it ends, so it is used only during a print. - **A job moved to a location or model could print from an empty external spool on a printer that has the filament in its AMS (#3239, reported by @hyyz17200)** — A job keeps the AMS mapping of the printer it was made for. When a job made for a printer without an AMS, which feeds from the external spool, was moved to a location and picked by a printer with an AMS, it still told that printer to use its external spool, and the print stopped at once with "External filament is missing". The check added for #2799 didn't catch it, because it read an external spool the printer reports as empty as "no information". Now, when the printer reports its external spool empty and its AMS holds the filament the job needs, in the same colour, the mapping is worked out again for that printer. A printer without an AMS keeps using its external spool as before, also when the spool's filament was never set, and with only another colour in the AMS the printer still asks for the spool rather than print in that colour. - **A job moved to a location or model could print in the wrong colour (#3239, reported by @hyyz17200)** — The fix above only covered the external spool. A job still kept the AMS trays of the printer it was made for, so a job for black PETG in tray 3 of one printer, moved to a location and picked by a printer with blue PETG in tray 3, printed in blue: the tray existed and held the right material, so nothing stopped it. A job that Bambuddy places on a printer by model or location now gets its trays matched on that printer, by material and colour, and is held for a spool when nothing fits. Moving a job from a printer to a model or location also clears its tray choice and any "Print Anyway" given for that printer. A job sent to one printer keeps the trays picked for it. A printer without an AMS whose external spool has no filament set now prints every filament from that spool; such a job used to go out without a mapping and stop with 0700_8012 "Failed to get AMS mapping table". - **The SD-card cleanup could delete a file the printer was printing (#3009, reported by @bdwilson)** — If a print finished while Bambuddy was stopped and you then reprinted the same file from the printer's screen, Bambuddy closed the old print when it came back and deleted its file from the SD card, even though the printer was printing that file right then. The cleanup now leaves a file alone while the printer is printing it. - **Permissions taken away from a group came back after a restart (#3238, reported by @Minebuddy)** — Turning off **View MakerWorld** and **Import MakerWorld** for a group and restarting Bambuddy turned them back on. Several permissions added in past releases were given to every matching group on each start, not just once on the upgrade that introduced them: MakerWorld, **Clear plate**, stock forecasting and slicer pipelines. Each is now given once, and a permission you remove stays removed. Updating doesn't re-enable anything you have already turned off. The built-in Administrators, Operators and Viewers groups, whose permissions can't be edited, are still kept complete on each start. - **A virtual printer with Save AMS mapping lost the slicer's external-spool pick (#3237, reported by @erabti)** — The slicer marks a filament fed from the external spool as -1 in its slot list, the same as a filament with no slot, and says which spool it is in a second list. Bambuddy saved only the first list, so the queued print sent that filament as unassigned. On an H2C the printer then stopped before the first layer with 0700-8012, "Failed to get AMS mapping table". The saved mapping now keeps the external spool, left or right on dual-nozzle printers, for the queued print and for reprints from the archive. - **A smart plug that reports energy in watt-seconds could not be set up (reported by @CLKRUN in #1251)** — A myStrom Switch reports energy in watt-seconds, and converting that to kWh needs a multiplier of about 0.000000278. The multiplier fields refused anything below 0.0001, so saving the plug failed. Any multiplier above zero is now accepted, for MQTT and REST plugs alike. - **The Statistics energy total stayed at 0 for Shelly and other REST plugs with only a lifetime counter (#3232, reported and contributed by @rojosinalma in #3233)** — With **Total consumption** tracking and no date filter, the Statistics page adds up each plug's lifetime counter, but for REST plugs it added the counter for today. Since #2539, a Shelly is set up with only **Lifetime Energy JSON Path**, as it has no counter for today, so its plug added nothing and the energy total and cost showed 0, while any date range showed the right figure. REST plugs now add their lifetime counter, the same as Tasmota and Home Assistant plugs. A plug with only a counter for today still adds that one. - **Running out of file descriptors could corrupt a SQLite database (#2883, reported and contributed by @M2ABRAMSTANK in #2884)** — Docker, the systemd service and most native installs start Bambuddy with a limit of 1024 open files. Once a busy instance used them all, every new database connection failed with "disk I/O error"; on the reporter's single-printer install that went on for 44 hours and ended with "database disk image is malformed" and a login outage. Part of the reason is how SQLite works in WAL mode: a database connection that closes keeps its file open until the last connection closes, so the database's share of open files stays at the most connections the pool ever reached. Bambuddy now raises its own open-file limit to the maximum the system allows when it starts, on every install, and logs what it set. The SQLite connection pool is smaller (10 + 90 instead of 20 + 200), which halves its share. A large printer farm still on SQLite that runs out of connections can raise `DB_MAX_OVERFLOW`, but is better served by PostgreSQL. The support bundle now counts open files by kind (database, sockets, pipes and so on) against the limit, so the next report shows what is holding them. Covered by backend tests. - **One model queued to several printers could print in the wrong filament (#2799, reported and contributed by @grolmus in #2803)** — An AMS mapping is a list of one printer's own tray numbers, and Bambuddy stamped the mapping it worked out for the first selected printer onto every other printer in the same dispatch. Where those printers hold the same spools in a different slot order the tray numbers still resolve, so nothing looks wrong, and each copy prints from whatever sits in that tray — in the reported case ASA where the 3MF requires PETG. An explicit mapping also bypasses the printer's own filament-type check, so nothing further down catches it. Every selected printer is now mapped against its own AMS, and a mapping already stored on a queue item is checked against the printer about to run it and recomputed when it does not fit, which covers a spool moved between queueing and dispatch as well. A tray of the right type in the wrong colour is still accepted, as it always was when nothing better is loaded. A slot you put on a tray of another material yourself in the print dialog is kept as you chose it. - **A job whose filament is not loaded waits instead of printing (#2799, reported and contributed by @grolmus in #2803)** — When a slot the plate prints cannot be matched to a tray on the printer it is about to go to, the job is held for a manual start and the queue row says which filament it needs, the same way a job short of filament already waits. This also catches a job that matched nothing at all: it used to be sent anyway and refused by the printer after the whole file had been uploaded, with `0700_8012 Failed to get AMS mapping table` and no explanation on screen. A job queued to a model rather than a named printer gives its printer back when it is held, so pressing start lets the queue choose among all printers of that model again instead of tying the job to the one that could not run it. That choice still goes by filament type only, so when the filament is on the wrong nozzle or the AMS changed after the printer was picked, it can pick the same printer and hold the job there again. A held job waits for someone to press start even once the right spool is loaded, as the filament-shortage hold already does. Start then finds the spool and the job prints, keeping any trays already chosen for its other slots. If the filament is still not loaded, Start says which one is missing and offers Print Anyway, which sends the job the way it went out before this change. The filament named on the queue row and in the notification is in English whatever the interface language. - **SpoolBuddy recognises a Bambu Lab spool from either of its two tags, and finds the spool the AMS already created (#984, block 9 read from @Keybored02's #1200, measured by @Thomansky and @Sawtaytoes)** — A Bambu Lab spool has a tag on each side. The two have different tag UIDs, but share one spool ID, the same one the AMS reports. SpoolBuddy read that ID from the wrong part of the tag: the bytes it used hold the filament type, so every "PLA Matte" spool sent the same "ID". The kiosk then didn't save it, so a spool was only known by the UID of the tag that was scanned. Scanning the other side offered to add the spool again, and a spool added on the kiosk was not matched when it went into the AMS, and the other way round. - The SpoolBuddy daemon now reads the real spool ID (block 9). **Update the SpoolBuddy daemon** to get the fix: on the kiosk, Settings → Updates. - Add to Inventory and Assign Spool on the kiosk save the spool ID with the tag, also when the spool was lifted off the reader while the dialog was open. In Spoolman mode the spool ID is stored as the spool's tag, as the AMS sync already does. - Bambuddy ignores a spool ID that is not a real one, so a SpoolBuddy that has not been updated yet keeps matching by tag UID as before and never saves the filament type as an ID. - Spools added on the kiosk before this fix get the spool ID the next time the tag they were added with is scanned. After that, the other tag and the AMS find them too. This only happens when the scanned tag UID matches the spool's exactly, the spool has no spool ID yet, and no other spool carries that ID. A near match on the tag UID, which a different reader can produce, never writes anything. In Spoolman mode the spool ID replaces the tag UID as the spool's tag. - **In Spoolman mode, lifting a spool off SpoolBuddy while Assign Spool or Add to Inventory was open lost its tag** — Both took the tag from the reader, which forgets it as soon as the spool is lifted, instead of from the card that stays on screen. Assign Spool then only reported that linking failed, and Add to Inventory created the spool without a tag and showed an error. Both now use the tag shown on the card, as local inventory already did. Bambu Lab spools were covered by the #984 fix above; this fixes it for every other tag. - **File names could vanish from the File Manager's list view (#3105, reported and contributed by @akshayy29 in #3119)** — With authentication on, the list has an extra Uploaded By column. When the folder sidebar left less room than the fixed columns needed, the Name column shrank to nothing, and the Uploaded By value sat right next to the thumbnail. The Name column now keeps at least 240px and the Tags column at least 96px. When there isn't room for both, the list scrolls sideways (it already could) instead of hiding them. - **A printer fault that kept coming and going was notified every time it came back (#3226, reported by @sgiffhorn)** — Bambuddy remembered which faults it had already notified as one list per printer, replaced on every status update, so a fault was forgotten the moment one update arrived without it. A 30-second grace period was meant to cover that, but only applied when the printer reported no faults at all, which never happens while it holds a notice such as the lubrication reminder. An H2D whose nozzle camera lens fault switched on and off beside two lubrication notices sent the same notification 9 times in 22 minutes. Each fault is now remembered on its own and notified again only after it has been gone for 10 minutes. So that a fault you fixed is still reported if it happens again soon, a fault seen before the print is resumed, started or finished is forgotten as soon as it is gone, without the wait; a filament runout fixed before resuming is notified again if it recurs a few minutes later. Covered by unit tests. - **The Printer Locations page had no way back to the Printers page** — the page is opened from the Printers page and is not in the sidebar, so the browser's back button was the only way out. It now has a "Back to Printers" link above the title. - **Inventory missed filament when two filaments of a print came from the same spool (#3230, reported by @Andrewdlec)** — once a print had charged a tray, any further filament from that tray was skipped. Mapping two of a project's filaments to one spool, which is common when a multi-colour model is printed in fewer colours, deducted only the first: the reporter's H2C charged 1.05 g of a 3.18 g print. A single-filament print that switched to another tray and back lost the last part the same way. Each filament is now charged on its own, with its own usage-history entry. Covered by unit tests. ### Security - **Bumped `dompurify` to 3.4.17, `zod` to 4.6.7 and Vitest to 4.1.12 for npm audit advisories** — DOMPurify 3.4.17 fixes three bypasses: `SAFE_FOR_TEMPLATES` with shadow roots, `IN_PLACE` with hooks that remove nodes, and the SMIL `attributeName` guard in XHTML mode. Bambuddy sanitizes project notes, project pages and MakerWorld descriptions with a plain `sanitize()` call and none of those options or hooks, so it was not exposed; the bump keeps the one runtime dependency of the three clean, and its floor in `frontend/package.json` moves to `^3.4.17`. zod 4.6.6 checks array, set and map size limits before parsing their elements, so an oversized collection can no longer exhaust memory; it is a development-only dependency, pulled in by `eslint-plugin-react-hooks` and `knip`, and is not in the shipped image. Vitest 4.1.12 hardens `@vitest/mocker`'s manual and redirect mocks; the test suite uses neither `__mocks__` folders nor redirect mocks, and the mocker runs only during tests. Vitest and `@vitest/coverage-v8` pin each other exactly, so `npm audit fix` alone left them on 4.1.11; both floors move to `^4.1.12`, and Vitest's own `chai`, `std-env` and `tinyrainbow` move with it. Verified with eslint, knip, typecheck, the production build and its Safari 16 baseline check, i18n parity and the full frontend suite (5207 tests across 394 files). `npm audit` reports zero vulnerabilities. - **Bumped the development tools' `virtualenv` to 21.14.9, `filelock` to 4.2.1 and `setuptools` to 84.1.0 for pip-audit advisories** — All three are development-only and none is in the shipped image or `requirements.lock`: `virtualenv` and `filelock` arrive through `pre-commit`, `setuptools` through `bandit[sarif]` and `pbr`. `requirements-dev.txt` now sets them as minimums, the way it already does for `msgpack`, so a development install audits clean. `filelock` 4 is a new major version; its breaking change is the on-disk format of `SoftReadWriteLock`, which `virtualenv` does not use, and `virtualenv` 21.14.9 accepts it. Verified with the pre-commit hooks, a bandit SARIF run, ruff, typecheck and the full backend suite. `pip-audit` reports zero vulnerabilities. - **Bumped `pyasn1` to 0.6.5 and `minimatch` to 11.0.4 for pip-audit and npm audit advisories** — pyasn1 0.6.5 fixes four decoder issues where crafted BER input could take quadratic time or memory (binary REAL mantissas, constructed BIT STRING and OCTET STRING fragments, and schemaless tag tracking); it ships in the image as a dependency of `ldap3`, which uses it for LDAP sign-in, so `requirements.txt` now sets `pyasn1>=0.6.5` as a minimum and both locks move to 0.6.5. minimatch 11.0.1 fixes a denial of service where a crafted glob made pattern preprocessing grow without bound; it is a development-only dependency of `eslint` and `typescript-eslint` and is not in the shipped image. There is no 10.x fix, so the `overrides` pin in `frontend/package.json` moves from `^10.2.1` to `^11.0.4`. Version 11 removes `makeRe()`, `.regexp` and the `AST` class, none of which eslint or typescript-eslint call; they only use `match()`, `minimatch()` and `.pattern`, and eslint still lints the same 809 files with no findings. Verified with the LDAP tests, ruff, typecheck, the full backend suite on SQLite and Postgres, eslint, knip, the production build and the full frontend suite. `pip-audit` and `npm audit` report zero vulnerabilities. ## [1.2.5.7] - 2026-10-02 ### Added - **A Column view in the File Manager (#3020, requested and contributed by @Thomansky in #3190)** — Next to Grid and List there is now **Column view**, as in the macOS Finder: one column per folder level, each listing that level's subfolders and then its own files, with the selected folder's files in the pane on the right. It shows where you are in the folder tree and lets you walk up and down it without going back to the sidebar. File rows carry the same actions as the list view (Print, Slice, Preview, Download, Details, Rename, Delete and the rest), and folder rows the same ⋮ menu as the sidebar. It works from the keyboard too: ↑/↓ move within a column, → or Enter go down a level, ← goes up, Enter opens a file's preview, Space ticks it, and the context-menu key or Shift+F10 moves into the row's actions. A file ticked in any column counts for the toolbar's Print, Preview, Combine, Move and Delete. With a search or tag filter active the columns step aside and the results take the full width, since they span every folder. The choice is remembered like Grid and List. Changing folders now clears the selection in every view, so Move and Delete can't act on files that are no longer on screen. The sidebar's folder ⋮ menu now closes when you click it again. - **Announcements from the Bambuddy maintainers, inside Bambuddy** — Security fixes, breaking changes, new releases and calls for testers now reach the people running Bambuddy where they already look, instead of only on GitHub and Discord. An **Announcements** entry sits at the bottom of the sidebar above the System icon for everyone who may see them, with a count of unread messages; it opens a list, and **important** and **critical** messages also show a banner until dismissed. Messages past their expiry stay readable under **Earlier** for a year (up to 50); withdrawn ones disappear everywhere. Read state is kept per user on the server. Bambuddy fetches one file, `feed.json` from the public `maziggy/bambuddy-notifications` repo on GitHub, at startup and every 6 hours: no Bambuddy server is contacted and nothing about the install is sent, and whether a message applies (version range, beta channel, install type) is decided locally. The file is signed with Ed25519 against a key built into Bambuddy, so a copy of the repo or anyone in between can't make it show a message, and an older file is refused so a withdrawn message can't be brought back. Messages are plain text, and links go only to github.com and bambuddy.cool. Shown to administrators; **Settings → General → Updates** can show them to every user, or turn them off entirely, in which case nothing is fetched. - **Camera snapshots reach more notifications and more providers, with an Attach Photo switch per provider (#3089, requested and contributed by @bbbenji in #3199)** — Plate Not Empty now carries a photo of the plate, taken before the chamber light is switched back off, and AI Failure Detection carries the frame the model flagged. Home Assistant (with a notify service set), Bark and Slack-format webhooks get photos too: they fetch the image themselves, so Bambuddy saves it under a random name and sends a link that opens only that one photo and stops working after 3 days. This needs **External URL** set in **Settings → Network**. Each provider has an **Attach Photo** toggle, on by default so nothing changes for existing setups, and **Test** sends a sample image when it is on. The print emails sent to the user who queued a job can show the finish photo inline when the template contains `{finish_photo_url}`. The template editor shows which events can carry a photo, and lists the variables for AI Failure Detection, Plate Not Empty, Plate Clear Required and First Layer Complete. The chamber light switched on for the plate check is now always switched back off, even when the check fails. - **Combine several STLs, or several copies of one, onto one plate (#2999, requested by @Markus98, contributed by @adman234 in #3162)** — The slicer sidecar slices one model at a time, so putting separate STLs on one plate needed a desktop slicer to build the 3MF first. Select one or more STLs in the File Manager and click **Combine to 3MF**, set how many copies of each you want, and Bambuddy saves a new 3MF with every object on one plate, with a preview image. Tick **Open the slicer when done** to go straight to the Slice dialog with auto-arrange already on. The source STLs are left untouched. A plate holds at most 100 objects, and the selected STLs can be at most 300 MB and 5 million triangles in total; each STL is stored once however many copies you place. - **The API-key printer status carries layers, the job id, HMS faults and the serial (#2919, requested by @simplytoast1)** — `GET /api/v1/webhook/printer/{id}/status`, the compact status route polled by add-ons such as notify-bambuddy for iOS Live Activities, returned only state, progress and time remaining. It now also returns `layer_num` and `total_layers`, the printer's `subtask_id` for the running job (a new value means a new print, even between two polls; `null` when Bambu gives the job no id), the live `hms_errors` in the same shape as the printer status route (so a filament runout reads differently from a manual pause), and the printer's `serial_number`. `remaining_time` stays in minutes for existing clients; the new `remaining_seconds` gives the same estimate in seconds, the unit notifications use. Nothing existing changed, and the route still needs **Read Status** and honours a key's printer limits. - **Choose what goes on a spool label, see it before printing, and save labels as PNG (#2981, requested by @apizz)** — Spool labels always carried the same lines, and you only saw the result once the PDF opened. The label picker now has a checkbox for each line: brand, material and subtype, colour code, name, storage location, material number, nozzle temperature, net weight, note, date added, QR code and spool ID. Material number, temperature, weight, note and date were never printed before, and leaving out the QR code gives the text its space. A preview beside the options shows the first selected spool's label as it will print, and updates as you change them. Lines that don't fit on the chosen size are left out rather than printed over the spool ID. Labels can now also be saved as PNG at 203, 300 or 600 dpi for label printer software that takes images, such as Brother P-touch Editor: one label comes as a PNG, several as a ZIP. The picker remembers the size, the lines chosen for each size, monochrome and the output settings in your browser. Spoolman labels now carry the same data as built-in ones, including the subtype and the colour name you set, where before they showed neither. - **SSO logins can keep a user's Bambuddy groups in step with their groups at the identity provider (#3107, requested and contributed by @willuhmjs in #3122)** — Each OIDC provider in Settings → Authentication → SSO/OIDC gains a **Group Claim** (default `groups`) and a **Group Mapping**, a list of rows pairing an identity provider group with a Bambuddy group. On every SSO login, existing accounts included, the mapped Bambuddy groups follow the provider: joining a provider group grants its Bambuddy group, leaving it removes that group at the next login. Groups the mapping doesn't name are left alone, so a manual promotion sticks. This is the same rule the LDAP group mapping uses. Groups are read from the signed ID token, as a list or as a space- or comma-separated string, and matched ignoring case. Claims with a namespace such as `app/roles` work, for Auth0. A missing claim, or a sync that fails, never blocks the login; the user keeps the groups they had. The form offers only existing Bambuddy groups, flags a row pointing at a deleted group, and won't save a half-filled row or a second row for the same provider group. The provider card shows whether group sync is on. With no mapping, a provider behaves exactly as before. Docker and env-var setups use `BAMBUDDY_OIDC_GROUP_CLAIM` and `BAMBUDDY_OIDC_GROUP_MAPPING`, a JSON object whose values are Bambuddy group names. A mapping naming a group that doesn't exist, or one that isn't valid, is refused at startup with the reason in the log, and the app still starts. - **Spools can carry your own material number, and statistics group by it (#2870, requested and contributed by @Thomansky in #2994)** — A new **Material No.** field in the spool dialog's Additional section holds the internal number you buy and cost a product by, such as `15` for Bambu Lab PLA Basic. Several spools of the same product share it. The field suggests numbers already in use, and surrounding spaces are trimmed, so `15` and `15 ` stay one number. A new spool of a product that already has a number gets it automatically: same brand, material, subtype and colour, whether it is added by hand, in bulk, through the API or by the RFID auto-add. CSV import is the exception: the file decides. The inventory has an optional sortable **Material No.** column, which sorts `2` before `15`, and a filter for one number or for spools without one. Search finds the number, and Bulk Edit can assign it to many spools at once. The Statistics page has a **By Material Number** widget: active spools and remaining stock per number, plus consumption and cost from the usage history, archived spools included, following the dashboard's date range. The number round-trips through CSV export/import and is part of the GitHub backup. The GitHub backup now also keeps a spool's category, low-stock threshold and storage location; a restore dropped them before. In Spoolman mode the number is Spoolman's own article number, shown read-only and edited in Spoolman. Bulk Edit there no longer offers category and low-stock threshold, which Spoolman cannot store, so setting them never took effect. - **Suppliers are a managed list, and each spool can record where it can be bought and where it was bought (#2988, requested and contributed by @Thomansky in #2996)** — A **Suppliers** button next to Locations on the Inventory toolbar opens the supplier list, in both inventory modes. In the spool dialog you assign any number of suppliers, each with its own article number and a quoted price per kg for comparing sources, and mark at most one as *bought here*. The quoted price is only for comparison; it never changes the spool's cost. You can add a supplier from the dialog without leaving it. A supplier still assigned to spools cannot be deleted. The inventory has an optional sortable **Suppliers** column, a supplier filter, and search matches supplier names and article numbers. A new spool of a product that already has suppliers gets the same list, but never the *bought here* marker. The Statistics page has a **By Supplier** widget: consumption and cost grouped by where each spool was bought, following the dashboard's date range. CSV export has `suppliers` and `purchase_supplier` columns. Import matches the names against your list and never creates suppliers: an unknown name is reported in the preview and that assignment is skipped. In Spoolman mode the assignments are stored in Bambuddy, next to the Spoolman spool. - **Find a spool by its label number, and assign it to a printer slot from the spool itself (#2978, requested and contributed by @pd81 in #2998)** — Typing `#3` in the inventory search now finds only spool 3. A plain `3` still matches anything containing it, such as #13 or #30, so existing searches behave as before. The spool pickers show each spool's `#ID`, so identical spools can be told apart. A spool with no slot now has an **Assign Spool** button in its editor, which is also where scanning a spool's QR code lands. It opens one dialog to pick the printer, then the AMS, AMS-HT or external slot. Nothing changes until you confirm, and a material mismatch still asks first. The SpoolBuddy kiosk offers the same printer choice when no printer is selected, instead of reporting it disconnected. On dual-nozzle printers the left external slot was labelled "Ext-R" in the assignment confirmation; it now reads "Ext-L". The AMS slots in the dialog can be picked with the keyboard, and screen readers announce each slot by name. - **Ambient drying can wait until the humidity stays high, so opening the AMS lid no longer buys a drying cycle (#2518, requested by @ryansouza, contributed by @M2ABRAMSTANK in #2895)** — Ambient auto-drying started on the first humidity reading above the threshold. Opening the AMS lid to swap a spool lets room air reach the sensor for a few minutes, and that brief spike was enough to start a cycle of up to 12 hours. A new **Require sustained humidity** switch in the **Queue Auto-Drying** card under Settings → Workflow, shown while **Ambient drying** is on, sets how long the humidity has to stay above the threshold before a cycle starts: 5 to 240 minutes, 15 when first switched on. It is off by default, so nothing changes until it is turned on. The 15 minutes come from a measured trace on an H2D: after the lid had been open for one to five minutes, every unit was back at or below 25% within about 12 minutes of opening, and the wait only starts counting at the first high reading. One reading at or below the threshold starts the wait over, and so does a gap of more than two minutes without a reading (a disconnect, or a print on a printer that isn't drying while printing), which is logged with the measured gap. A missing reading changes nothing. A printer with a scheduled print pending still starts at once, for every AMS unit on it, because that drying has a deadline. The wait also applies to a printer that is printing with **Continue drying while printing** on, but not while ambient drying itself is off. It runs alongside the cooldown after a cycle instead of being added to it, never interrupts a running or manual cycle, and does not log a wait for a unit the printer refuses to dry. Translated in all locales. - **Other applications can send messages through your notification channels** — A new switch per notification provider, **Messages from connected apps** (off by default), lets an application such as Bambuddy Orders send its own messages ("3 orders need you") to Telegram, ntfy, email and the other channels. The app calls `POST /api/v1/notifications/app-message` with an API key that has the new **Send Notifications** permission (off by default; the key's owner also needs `notifications:update`). Messages go through quiet hours, the daily digest and the notification log like Bambuddy's own. Text is plain, links must be http(s), and each key is limited to 20 messages a minute. `GET /api/v1/notifications/app-message/channels` tells the app which channels take its messages. The check behind the electricity-price door and this one is now one shared helper. - **The streaming overlay can show the printer model (#3080, requested and contributed by @adamspicedev in #3099 and #3134)** — Settings → API Keys → Streaming Overlay has a new **Printer model** checkbox, which adds `model` to the overlay URL's `show=` list. It can be picked together with the printer name or on its own. With both, the overlay shows `Big Mumma · H2D`; with only the model, `H2D`. A printer with no model stored shows the name alone, without a stray separator. The model is off by default, so every overlay URL already set up in OBS looks the same after upgrading. The token-authenticated feed that OBS reads (`/printers/{id}/overlay-status`) now returns `model` for connected and disconnected printers; it exposes nothing else new. A long name plus model in a narrow OBS source is now cut off with an ellipsis instead of running past the edge of the panel. A printer saved by an older version under a discovery code such as `BL-P001` or `O1D` shows its model name (`X1C`, `H2D`), as on the Printers page, instead of the code. - **A second streaming overlay design, for portrait and landscape sources (#3177, requested and contributed by @adamspicedev in #3183)** — Settings → API Keys → Streaming Overlay has a new **Artwork** choice: **Classic**, the overlay as it has always looked, or **Version 2**, which adds `artwork=2` to the overlay URL. Version 2 lays itself out for the browser source's shape. In a landscape source the camera fills the frame under a translucent panel at the bottom; in a portrait source (1080 × 1920, for vertical streams) the printer name sits in a header, the camera fills the middle and the print data sits in a panel below it. Portrait crops the camera to fit rather than stretching it, so the sides of the build plate can be cut off. The fields, text size, frame rate, camera rotation and Streaming Overlay token all work as before, and a camera turned 90° still fills its space in either layout. It hides progress, layers and temperatures while the printer is offline, and a finished or failed print shows its status instead of "Printer is idle". The URL names a version number rather than something like "new", so a scene set up today keeps its look when a later design arrives. Classic stays the default, so every overlay URL already in an OBS scene looks the same after upgrading. - **Queue cards show the filament each job will print with (#3132, requested and contributed by @bgrr74 in #3184)** — Each job in the print queue now shows a colour swatch and name for every filament it uses, so you can check the next colours without opening each job. When a job is mapped to AMS slots on a specific printer, the card names the slot, the spool bound to it and its colour, for example "A1 · eSUN PLA Basic · Bone White", read from the printer's current AMS contents. Jobs without a slot mapping yet, such as "Any P2S", show the colour chosen when queueing, or the colour from the 3MF for the selected plate. Jobs with more than two filaments show just a row of swatches, with the full list on hover. If a mapped slot has been emptied since the job was queued, the card says so in yellow ("A3 · Empty · Caramel"); a spool without an RFID tag is never reported as empty. - **An app shown in the sidebar can match Bambuddy's theme** — An external link that opens inside Bambuddy now tells the framed page which theme is showing: light or dark, background, style and accent. It sends this when the page loads, whenever the theme changes, and when the page asks with a `bambuddy:theme-request` message. The message is `{type: "bambuddy:theme", mode, style, background, accent}` and goes only to the link's own origin. It carries nothing but those names. Pages that don't listen are unaffected. - **Connected apps: other applications can sign people in with their Bambuddy account** — Settings → API Keys → Connected Apps registers an app with one exact callback URL and gives it a client ID and a secret, shown once. The app sends the browser to `/connect/authorize`; the user confirms once ("Allow"), and after that sign-in is automatic, so an app opened from the sidebar needs no second login. The app's server then swaps a single-use code for the user's name, email, groups and permissions at `POST /api/v1/connect/token`. It is the OAuth 2.0 authorization-code flow with PKCE (S256 only), kept to what an app on the same network needs: a code lives 60 seconds, works once, is bound to its app, callback URL and PKCE challenge, and is redeemable only with the app's secret; codes and secrets are stored hashed, failed exchanges are rate-limited per app and per IP, and the page never redirects to a callback URL the backend has not matched against the registration. The app never receives the user's password or Bambuddy session, and an API key cannot sign its owner in to an app. Connected apps require authentication to be enabled; with it off, both endpoints answer `auth_disabled` so an app falls back to its own login instead of letting everyone in. The consent page may be shown inside Bambuddy's own frames (`frame-ancestors 'self'`), so an app opened from the sidebar can sign in there; every other page still refuses all framing. See the wiki page *Connected Apps*. - **A batch can record the external order it fulfils, so an integration can't queue the same order twice** — `POST /queue/batches` accepts `external_source` (e.g. `shopify`) and `external_ref`, a reference to the record in that system; both are returned on every batch and can be filtered on with `GET /queue/batches?external_source=&external_ref=`. The pair is unique: a second create for the same record answers 409 instead of making a second batch, so a connector that retries after losing the response can't print an order twice. Both fields are optional and must be given together; batches created without them behave exactly as before. - **Colour swatches for printer slots in the Print / Schedule dialog's filament mapping (#3159, requested by @frantiseklorenc)** — The dialog's filament mapping compares the colour the slice asked for against the colour actually in the machine, and until now only the left-hand side of that comparison had a swatch. The right-hand side — the slot, and every slot in its dropdown — was text, and the text is not reliable: a slot's colour name comes from the Color Catalog or, failing that, from hue, so a third-party beige is announced as "Orange". On a farm running twenty or thirty non-Bambu colours that are swapped between machines daily, a "Color mismatch" warning then gives no way to tell a real mismatch from two names for the same hex without opening the printer card in another tab. Each slot now carries its colour, in the picker and on the row, together with its hex; the slot whose colour is exactly the one the slice asked for is ticked. It works for a slot bound to an inventory spool and for one configured through Configure Slot or on the printer itself, which is the distinction that mattered — the second kind has no inventory row behind it and draws the colour the printer reports. A bound spool also contributes what a tray record cannot carry: its remaining colour stops and its effect, so a two-tone or glittery spool draws as itself rather than as its base colour. The same treatment is applied to the filament-override picker used for model-based assignment, which is the same choice on the other dispatch path and would otherwise have stayed text-only. Both controls stop being `` 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 (`