Updated CHANGELOG

This commit is contained in:
maziggy
2026-10-02 15:40:36 +02:00
parent 56e067753a
commit bb3a846a98
+141 -33
View File
@@ -2,7 +2,7 @@
All notable changes to Bambuddy will be documented in this file.
## [1.2.5.7] - 2026-10-02
## [1.2.6b1] - Unreleased
### 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.
@@ -11,23 +11,54 @@ All notable changes to Bambuddy will be documented in this file.
- **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.
- **The outcome prompt can be answered by reacting to the Telegram message (#3046, requested and contributed by @Thomansky in #3129)** — The Good/Reject buttons in a Telegram outcome prompt open a Bambuddy link, which only works when the phone can reach Bambuddy. A thumbs-up or thumbs-down on the message now records the verdict instead: the phone only talks to Telegram, so it works away from home, behind NAT or with the VPN down, and needs no external URL. A Telegram provider gains **Outcome verdict via**: **Inline buttons (link)** (the default, unchanged), **Reaction (👍 / 👎)**, or **Buttons and reaction**. Bambuddy then polls the bot for reactions (one poll per bot, outbound only) and edits the message to say "marked as good" or "marked as reject". The first verdict wins, whichever way it arrives, and a reaction stops counting exactly when the prompt's links would, so a message from an earlier run can't answer a reprint. Use a bot only Bambuddy uses for reactions: another app polling the same bot, such as Home Assistant's Telegram integration, stops receiving its messages. In a group the bot must be an admin to see reactions. The Archives page's **Unconfirmed** filter now lists completed prints only, like the **outcome?** badge.
- **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.
- **Library files can carry an external link, notes and photos (#3077, requested by @SergioFuchs, contributed by @Thomansky in #3128)** — The File Manager now gives library files what archives already have. **File details** in a file's card menu, or the info icon on a list row, opens a window with the file's facts (size, print details, the MakerWorld source if there is one), an editable notes field, a link, and a photo gallery. Photos can be JPG, PNG or WebP, up to 10 MB each, and open full size when clicked. Only http and https links are accepted. The grid shows a globe icon that opens the link, a notes marker and a photo count. Edits you haven't saved survive the file being refreshed in the background. A file's photos are deleted with it when the trash is emptied.
- **File Manager previews open larger, zoom, go fullscreen and cover images (#2976, requested and contributed by @Thomansky in #2990)** — PDF, spreadsheet and 3D previews now open in the same large window, with a fullscreen button; double-click the preview to toggle fullscreen. PDFs zoom with Ctrl/⌘ + wheel, a trackpad or touch pinch, or the `+`, `-` and `0` keys. In the 3D viewer the wheel no longer zooms past the model into empty space, and the new zoom and reset buttons return to the framed view. PNG, JPG, GIF, WebP and BMP files get a preview with zoom and pan; TIFF keeps its thumbnail but no preview, because most browsers cannot show it. Double-click a file in the grid or list to open its preview, or select one file and use **Preview** in the toolbar. PDFs get **Generate Thumbnail** in the card menu and list row, like STLs. PDFs with Japanese, Chinese or Korean text, JPEG2000 images or ICC colour now render correctly; they came out blank or wrong before, because pdf.js could not load its font maps and image decoders. The page's security policy no longer allows WebAssembly; only the two preview workers that need it do. Very large zooms on iPhone and iPad stop gaining sharpness instead of showing a blank page. All previews run in the browser, not on the Bambuddy server.
- **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.
- **A link can open one batch order** — `/queue?batch=<id>` opens the Batches tab with that order highlighted and scrolled into view, whatever its status.
- **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.
- **An app shown in the sidebar can open a Bambuddy page in place** — The frame Bambuddy shows an external link in can't navigate the Bambuddy window, so a link such as Bambuddy Orders' "In Bambuddy" did nothing when clicked inside the sidebar. The framed page can now ask with `{type: "bambuddy:navigate", path: "/queue?batch=12"}`, and Bambuddy opens that page as if its own menu had been used. The request is accepted only from the frame, only from the link's own origin, and only for a path inside Bambuddy; anything else, such as another site or a `javascript:` address, is ignored. The theme message now carries `canNavigate: true`, so a page can tell this Bambuddy from older ones and open a new tab there instead. The frame's sandbox is unchanged.
- **Post-print outcome confirmation: say whether a completed print is actually a good part (#1898, requested by @FedericoPuntelli, contributed by @Thomansky in #3047)** — The printer can only report that a print *completed*; a warped, out-of-tolerance or wrong-colour part counted as a success anyway. A print can now ask for a verdict: turn on **Ask for Outcome** in the print dialog, or set it as the default under Settings → Workflow → Queue & Dispatch → Default Print Options, and when the print finishes Bambuddy asks "How did your print come out?" with the finish photo and **Good** / **Reject**. It can be answered in the dialog that opens in the web UI, from the printer card next to the plate-clear button, or from the phone: the new **Outcome Confirmation** notification event puts Good/Reject buttons into ntfy and Telegram notifications and a link to the dialog into every other channel. A reject takes an optional reason from the failure-reason list and offers **Print again**. *Ask me later* leaves an amber **outcome?** badge on the archive card, and the Archives page gets an **Unconfirmed** filter; the verdict stays editable from the card's menu or Edit Archive, and says where it came from (in the app, a link, the printer card, or a plate release). Two further options, both off by default: **Also ask for prints started outside Bambuddy** gives prints started at the printer, in Bambu Studio or in the Handy app the same prompt, and **Count unanswered outcomes as good on plate release** (Plate-Clear Confirmation card) records a still-open prompt as good when the plate is released. Rejected prints don't touch the machine failure rate; Failure Analysis shows them as their own line with a yield figure, and project part counts only count parts that were not rejected. A one-tap link shows a confirmation page and records nothing on a plain open, so a link preview, a mail scanner or a browser prefetch can't answer the question for you; only the notification buttons record in one tap. Nothing changes for anyone who doesn't turn it on: the option is off by default, and the notification event only fires for prints that asked.
- **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.
- **PDFs in the File Manager get their thumbnail the moment they arrive (#2976)** — A PDF's grid thumbnail used to exist only once somebody had opened its preview, because the browser rendered it and posted it back; until then the card was blank. Page one is now rendered on the server with PDFium (`pypdfium2`, a new dependency whose wheels carry the PDFium binary for every platform Bambuddy ships, so no system package is needed) on upload, inside an extracted ZIP and when an external folder is scanned. **Generate Thumbnails** covers PDFs too, so existing ones can be backfilled in one click; its tooltip and its nothing-to-do toast say so in all 15 languages. The thumbnail has the same shape as the browser's (a 256 px square, page centred on white), so old and new cards match. PDFium is not thread-safe, so renders are serialised behind a lock and run off the event loop; the render scale comes from the page's own size, so a PDF declaring a huge page cannot make it allocate a huge bitmap. A PDF it cannot read (damaged, encrypted) gets no thumbnail and still lands in the library, and the browser preview remains the fallback.
- **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 `<select>`s to do it — an `<option>` renders text and nothing else — and both keep full keyboard operation, listbox semantics and the border colouring that encodes match, same-type-different-colour and not-loaded.
- **Swedish (sv) is now a supported interface language (#3052, requested and contributed by @AntonPalmqvist in #3062)** — Adds `sv` as the fifteenth locale, listed as "Svenska" in the language picker. The file arrived in better structural shape than any locale contribution so far: 6302 leaves in 458 objects, the same shape as `en.ts` down to the nesting, no duplicate keys, no keys `sv` has that `en` does not, plural suffixes exact, and every `<Trans>` component tag preserved — French has quietly dropped `<strong>` from five strings and nobody noticed, so that is a real bar. The 74 entries in the new `SV_COGNATES` allow-list were each checked against the strings actually identical to English: every one is load-bearing, none dead, which is what the list's "curated, not a shortcut" comment claims. Four things needed correcting before it could be wired up. The submission was clean on the day it was made and then went stale: thirteen keys and three placeholder sets landed on `dev` in the fortnight that followed (#3051, #3063, #1334, #3090, #3092, #3114, #3110), so `{{id}}` in `inventory.tagAlreadyLinked` and `{{runtime}}` in two `diagnostic.check.network_mode` strings were missing and the parity gate was red through no fault of the translation. More consequential, three literal identifiers had been translated along with the prose around them: `device/<serial>/report` and `{{prefix}}/printers/<serial>/status` are MQTT topics on the wire, and the column names in the CSV import hint are what the importer matches on — `märke, subtyp, färgnamn` would have told a Swedish user to build a file that cannot import, so the columns are verbatim again and only the word describing them is translated, the way `de`, `fr`, `nl`, `it` and `es` do it. The four `backup.repoUrlPlaceholder` values were changed back from `exempel.com` to `example.com`, which is the domain reserved for documentation; `exempel.com` belongs to somebody. And the file had been using both `slicer` and the `skivare` family for the same word — a problem in a translation that says `skrivare` or one of its inflections 377 times, with `skivaren` one letter away from `skrivaren` and both words landing in the same sentence in `diagnostic.check.printer_publishing.pass`. It is `slicer` throughout now. Three strings left in English were also translated rather than exempted (`Support` → Stöd, `Teal` → Blågrön, `Display` → Skärm), which retired three cognate entries. Parity green at 6302 leaves across all 15 locales.
- **Sub-projects on the Projects page can be folded away (#2991)** — A project with sub-projects drew every one of them expanded underneath it, at every level, with nothing to shut. That is fine for two projects and unusable for a three-level hierarchy over a couple of hundred archives, where the page becomes one long scroll before the first thing you were looking for. Each group's "Sub-projects of X" caption is now a chevron that folds that group, and a **Collapse** pill next to the status filter tabs sets the default for the whole page and is remembered across reloads. The caption stays visible when a group is shut — it is the way back in, and it carries a count of what is behind it. That count is of the cards actually nested there, not the card's own sub-project badge: the API counts sub-projects across every status on purpose, so under the default Active filter the badge can legitimately say 2 where only one card will unfold. A group folded by hand deviates from the default until the pill is pressed, which resets those deviations rather than leaving a group defying the switch that was just flipped. Nothing changes for a page with no nesting, where the pill is not shown at all, and the default with no stored preference is still fully expanded.
- **A spool can carry a different filament preset on each printer model, and its K profiles are picked per hotend** — A slicer preset is bound to a printer model: `Bambu PLA Basic @BBL X1C` is not the same preset as `@BBL H2C`. A spool stored exactly one, which was right until the same spool was used on a second model — the AMS slot on the other machine was then configured with a preset that machine has no profile for. The spool form's PA Profile tab is now a **Printers** tab holding both halves of the answer: a model list on the left, and on the right that model's filament presets and the K profiles for each of its hotends. Presets are keyed on the printer *model*, because `@BBL X1C` is the same preset on every X1C you own and asking once per machine would mean picking the identical value twice; K profiles stay keyed on the individual printer, extruder and nozzle diameter, because a K value is measured on one physical hotend and two machines of the same model legitimately differ. Both halves cover **every nozzle size — 0.2, 0.4, 0.6 and 0.8 — not only the size currently fitted**, because a spool is configured once and nozzles get swapped: presets get a row per size (the preset is written to an AMS slot, a slot feeds exactly one nozzle, and Bambu names its presets per size anyway), and K profiles are laid out as a grid with size down the side and hotend across the top, a dash marking a size the printer has no calibration for. Anything left alone inherits the spool's own preset and keeps inheriting it when that changes later, so only what actually differs needs an override, and a spool nobody has configured behaves exactly as it did before. Each model is offered only the presets that name it — using the same matcher the Configure AMS Slot modal filters with, now shared between them — while presets whose name identifies no model, which is most user-authored and OrcaSlicer ones, stay available everywhere, and a preset already saved is never hidden from the control that shows it. Every preset carries an origin badge — Bambu Cloud, Orca Cloud, Local or Built-in — in the same wording and colours the Configure AMS Slot modal has used since #1623, because the same filament exists in several of those sources and which one is picked decides what actually reaches the printer. **Auto-match** fills every size of every model with the variant of the spool's preset that names it, preferring the variant for that exact size; a model with no such variant is left inherited rather than given an approximate one. The model list stays one row per model however large the fleet is, and carries a count of hotends still without a K profile so an unfinished spool is visible without opening anything. One limit worth knowing: a per-model override can be one of your own cloud presets, whose id the slicer refuses in a slot's filament field, so such an override configures the slot but is not used as the calibration link — the spool's own preset is used there instead.
- **K profiles distinguish High Flow from Standard nozzles** — A printer files each calibration under a nozzle id of the form `HH00-0.4` (high flow) or `HS00-0.4` (standard) and can hold both for one diameter — a maintainer's H2D carries 102 high-flow entries and 6 standard — because the same filament reads a different K through each. The picker labels every profile with the flow it was measured on, saving records it, and a stored profile is no longer applied when the fitted nozzle disagrees; the picker marks such a profile rather than letting it look configured while quietly doing nothing. Two spellings have to agree for that: a calibration entry says `HH00-0.4` while the fitted nozzle reports `HH01`, so the comparison is two characters, not four. An unknown flow on either side matches anything, which is what it must do — every K profile stored before this has none, and an X1C declares none on any profile at all (measured: all eight come back with an empty nozzle id) even though the machine really does take either nozzle, so treating silence as "Standard" and filtering on it would have dropped every X1C profile the moment a high-flow nozzle was fitted.
- **Every path that configures an AMS slot respects a spool's per-model preset and per-hotend K profile** — The manual assign in either inventory mode, the RFID auto-assign, the Spoolman tag link, the re-fire when a slot goes from empty to loaded, the re-apply after a calibration-table refresh, and the re-selection when a Filament Track Switch moves an AMS to the other nozzle. The Configure AMS Slot dialog also opens on the spool's own configured values, falling back to the slot's last manual configuration and then the tray's RFID data, rather than ignoring what the spool was configured with on the one screen that looks like it exists for it. A printer card in expanded view now lists every fitted nozzle size rather than the first entry alone, which on a machine with two different sizes named one hotend and implied it was the whole printer.
- **An Avery sheet can start at the first unused position, so a part-used sheet is not thrown away (#2879, requested and contributed by @whitigol in #2918)** — Label PDFs always began in the top-left slot, so the second batch printed onto a sheet that already had seven labels taken off it would have printed over the gaps. Spool labels are printed a few at a time as filament arrives, which meant spending a 30-slot Avery 5160 sheet on two labels or nothing. A **Starting label position** field in the print-label dialog now says which slot to begin at, counted the way the sheet reads — left to right, top to bottom, starting at 1. The offset applies to the first page only and later pages restart at slot 1, because the number describes the sheet already in the tray rather than anything about the job: the second sheet the printer pulls is a fresh one. Position 1 is the default and is what a request that omits the field means, so both label endpoints — built-in inventory and Spoolman — behave exactly as they did for anyone who never touches it. The bounds are per template and enforced on both sides, 1–21 for Avery L7160 and 1–30 for Avery 5160; the server derives them from the sheet layout table it already lays labels out from rather than carrying a second copy of the numbers, so the two cannot drift, and a non-default position is refused outright for the single-label roll templates where a sheet slot means nothing. In the dialog the two sheet buttons disagree about the same number — 25 is valid on a 5160 and off the end of an L7160 — so the button whose capacity the value exceeds is disabled and its hint is replaced by that sheet's own range, instead of leaving a disabled button next to guidance that says the value is fine. The sentence naming the skipped slots deliberately avoids i18next's reserved `count` variable: with no `_one` form defined, a value of 1 resolves past the translation to the English default, so every non-English locale would have printed an English sentence at position 2 and only at position 2. Translated in all 13 locales, README and wiki updated, and covered by renderer, endpoint and dialog tests including the page-boundary cases where the selection exactly fills the offset first sheet and where it runs one past it.
- **Home Assistant sensors can be bound to a storage location, so a drybox reports its own humidity (#2824, requested and contributed by @MagicMelody84 in #2827)** — A temperature, humidity or battery entity can be attached to any storage location, and its reading shows where the filament in that location is already being looked at: on the spool cards in Inventory, and in three optional Inventory table columns that are off by default. Values can be colourised against the location's own thresholds, so a rack of dryboxes reads at a glance rather than one number at a time. Each binding carries its own alert rule — above/below for a numeric sensor, on/off for a binary one — and can raise a notification when it crosses into that state, on the edge only: a drybox that was already too humid when Bambuddy restarted has not just become too humid, and re-announcing every pre-existing alert on every restart is how people learn to ignore the alert. That notification is its own event with its own provider toggle and template rather than sharing the printer sensors' one, because a provider can be scoped to a single printer and a location has no printer to scope by — sharing the column would have delivered drybox alerts to a channel deliberately narrowed to one machine, with no way to have one without the other. A background poller reads the bound entities on a configurable cadence, defaulting to two minutes and floored at one, and polls before it sleeps so a restart does not leave every card blank for a full interval. Nothing is polled for a location that has no sensor bound, and in table view nothing is polled at all while the three columns are hidden, so the installs that never touch Home Assistant — which is most of them — issue no extra requests. New bindings are seeded from defaults that live on the server, not in the browser, since they become the alert rule written onto each row and two admins binding sensors from different machines must not seed different rules; the colour choices stay per-browser, because those are only how one person likes to read the page. Home Assistant's `moisture` class is deliberately not offered: it is a binary wet/dry leak detector rather than a humidity percentage, and treating it as one put "wet" in a percent-formatted column and let it block the real hygrometer on the same location. One entity can be bound to a location once, enforced by a unique index rather than by the check alone, because that check is read-then-insert and two concurrent creates could both pass it; the upgrade collapses any duplicate that slipped in before the index existed instead of failing to build over it. An API key can read the bindings but not create or edit them, matching the printer bindings exactly. Translated in all 13 locales, with backend and frontend regression tests throughout.
- **A fault's description is in the status response, so a client no longer needs its own copy of the table (#2926, proposed and analysed by @sadontsev)** — `HMS_ERROR_DESCRIPTIONS` has been in the backend all along and the status response never carried it, so every consumer that wanted to tell a user *why* a print halted resolved the same 853 codes from its own duplicate of the same sentences — this repo's Python table, the frontend modal's, and at least one third-party iOS client whose catalogue exists purely because the server would not say. Each aged separately, and a push relay watching a printer could only manage "your printer needs attention" while the server already knew it was "Filament ran out. Please load new filament." `hms_errors[]` entries now carry `description`, defaulting to null so a client that has never seen the field is unaffected. It is resolved once, where the fault is parsed, rather than at the boundary that happened to prompt the request: there are three separate serializers of a fault — the status response, the WebSocket broadcast, and the print-completion payload the queue's failure reason is built from — and adding it to only the first would have delivered half the feature to a relay watching the stream, which is the likelier consumer. The queue's failure reason now quotes the same sentence instead of resolving the code a fourth time, and the notification path reads it rather than re-deriving its own. Resolving in one place is also what makes the three unable to drift, which is pinned by a test that asserts they agree. Resolution is exactly what the codebase already did, verified rather than assumed: an 8-char `print_error` is the catalogue's `MMMM_EEEE` key split in half, and a 16-char `hms[]` identifier is tried whole and then collapsed to its first and last groups, which is how the notification path, the queue's failure-reason helper and the frontend modal have always resolved those. The collapse is lossy — #2728 counts 65 documented faults falling onto `0300_0001` alone — and it is kept rather than tightened here because refusing it would not read the same data more strictly, it would stop describing faults that are described today and leave this field null while the UI shows text for the same fault. Narrowing it belongs with #2728, where both key spaces can move together. A fault the catalogue does not cover reports null and is still reported in full; `full_code` identifies it either way. The equivalence is pinned by a test that checks every catalogue code in both fault shapes across all three alert levels, so a future change to the lookup cannot silently stop notifications from firing. The text is English only and unlocalized, which the schema says next to the field. The frontend keeps resolving its own text for now; switching it over would change what `filterKnownHMSErrors` counts across eight call sites, which is #1840 and #2728's argument rather than this one's.
- **A virtual printer can be told which address to advertise, so uploads work on Docker bridge networking (#2930, reported and diagnosed by @sebimarkgraf)** — `VIRTUAL_PRINTER_ADVERTISE_ADDRESS` sets the address written into the MQTT status that BambuStudio and OrcaSlicer read their FTP upload destination from. It exists for deployments where that address is not one of the container's own interfaces: on bridge networking the virtual printer is reached on the host's LAN IP but binds a private one like `172.24.0.2`, and that private address is what the slicer was handed — so it opened an FTP connection to an address that does not exist on its network, which is the upload stalling around 10% with "Failed to send" that the troubleshooting page has been describing as a limitation with no fix. Set it to the address slicers use, alongside the `VIRTUAL_PRINTER_PASV_ADDRESS` that already existed for the passive-data channel. The log line that arms the rewrite names its source, so `(VIRTUAL_PRINTER_ADVERTISE_ADDRESS)` versus `(bind_address)` says whether the variable reached the container. A value that is not a dotted-quad IPv4 is refused with one warning naming it and the address that would have been used before is used instead — deliberately, because refusing to rewrite at all would put the *real printer's* IP back in front of the slicer, which is the leak this path exists to close and strictly worse than the wrong local address. `0.0.0.0` counts as unset, and surrounding whitespace is tolerated for the sake of values pasted into a compose file. This is an environment variable rather than a change to how the advertised address is resolved, and that was the decision worth making carefully: the virtual printer already has a "Network Interface Override" field, but it feeds SSDP and the certificate's SAN list only, and reading it here would have moved the upload destination on every install that has one set — the multi-NIC, VLAN and Tailscale setups, which are the ones most likely to have been arrived at by hand and the least likely to survive being second-guessed. Unset, nothing about the resolution changes, which is pinned by a test. Host and macvlan networking still need none of this and remain what Virtual Printer is developed against; the variable removes one blocker rather than making bridge mode equivalent, and the wiki now says so in the three places that previously stated the host address could not be discovered at all.
- **Printer file downloads can be selected in ranges and print-history videos can be downloaded (#2850, requested and contributed by @logikal in #2853)** — The printer file browser's multi-select download now prepares large selections on the app data volume instead of buffering them in server and browser memory, uses per-file compression (videos stored, G-code/3MF compressed), reports partial results and preparation progress, supports cancellation, rejects over-large or under-space selections, and preserves the legacy API contract. Shift-click selects a contiguous visible range and hidden selections are discarded when navigating or filtering. Print History now offers attached timelapses, matching printer timelapses, and `/ipcam` chunks when available; offline or unreadable storage is distinguished from an empty directory. Download tokens are single-use and resource-bound, API-key printer allowlists are enforced, FTP short reads are rejected, and abandoned staging is pruned. Translated in all locales; wiki updated. Covered by backend and frontend regression tests.
- **Bambuddy now asks a printer that refuses FTPS what it actually said (#2780, measured by @grolmus)** — When a printer's file service answers port 990 with something that is not TLS, Python reports `[SSL: WRONG_VERSION_NUMBER]` and the bytes that caused it are gone, consumed by the TLS layer before the error surfaces. That has left #2780 open on a theory rather than a finding. The client now opens one plain connection straight afterwards and reads what the printer says, so the log carries the printer's own words — an FTP refusal such as `421 Too many connections` would identify the fault outright — and the line is marked as the one to quote in a report. Reading nothing is informative too, and says so: a healthy implicit-FTPS service stays silent until it gets a handshake, so silence means the refusal had already passed. It asks once per cool-off window rather than once per attempt, which keeps it to one extra connection per printer per five minutes — the suspected fault is a printer running out of connections, so the diagnosis must not add to it. What made this worth doing is a measurement from a nine-printer farm, reproduced here: a cleartext banner on the TLS port produces exactly the error the field reports, a genuine TLS version mismatch produces a different one, and a client with no version cap reaches a TLS-1.2-only peer unaided. So this failure was never a TLS-version problem, and the per-model `cap_tls_v1_2` knob cannot affect it. Two of the three entries carrying that knob were added on the belief that it could; they are kept, since their reporters saw the symptom clear and nobody here has the hardware to re-test on, but they are now marked for re-test and the reasoning recorded next to them is what was measured rather than what was assumed. Both measurements are pinned by tests, so the explanation stays falsifiable.
- **The K value is on the AMS slot itself, not only in the popover (#2532, requested and contributed by @gyrene2083)** — Reading back a slot's pressure-advance value meant hovering it: the K factor lived in the filament popover alone, so checking whether a calibration had actually taken across four slots was four hovers, and comparing two of them side by side was not possible at all. Every slot card now carries the value under the material name, the way Bambu Studio shows it per slot — on regular AMS units, on AMS-HT, and on the external spool of a dual-nozzle machine. Only a value the printer actually reported is shown: a loaded but never-calibrated slot stays blank rather than inheriting the 0.020 that fills the popover's own field, and a slot the firmware reports as exactly 0 counts as uncalibrated the same way the stored K-profiles do. The label is shortened to **K** with the full localized name on hover, because "K Factor", "K-Faktor" and "Facteur K" ate the value itself — the whole point of the line — on cards under about 350px, and the figure is set in tabular numerals so it measures the same in Safari as in Chromium. Where one slot of a unit is calibrated and its neighbours are not, the neighbours hold the same row open so the fill bars stay level across the card.
- **Dutch (nl) is now a supported interface language (#2891, requested and contributed by @Igiegel)** — Adds `nl` as the fourteenth locale, listed as "Nederlands" in the language picker. The translation was contributed as a file on the issue and needed three corrections before it could be wired up, all of which the parity gate found. First, the nine `stats.timeframe.*` entries had their **keys** translated along with their values (`'today'` had become `'vandaag'`), which would have left the Statistics timeframe selector resolving nothing and rendering raw key names for every Dutch user — the values were kept and the keys restored. Second, the file was translated against an older `en.ts` and was 84 leaves short, missing the Filament Track Switch feed prompts, the AI-detection status strings, the no-3MF internal-history banner, the batch-order stranded-plate notices, the Avery starting-position field and the whole `locationHaSensors` section from #2824; those were translated and added. Rather than splice them in, `nl.ts` was regenerated from the `en.ts` skeleton with the contributor's strings carried over, so its structure, key order and section comments now match the reference locale exactly and a future diff against `en.ts` reads as content rather than as reordering. Third, 229 leaves were identical to English; each was checked individually and all were kept, because Dutch takes most technical UI vocabulary verbatim — printer, filament, status, nozzle, timelapse, dashboard — and Dutch slicer users use the English feature names (support, ironing, prime tower, gap fill) untranslated. Those 123 distinct values are now listed explicitly in a `NL_COGNATES` allow-list in `check-i18n-parity.mjs`, the same shape the other twelve locales use, so the exemption is an enumerated translator decision rather than a blanket skip. Parity green at 6264 leaves across all 14 locales.
### Changed
- **The Slice dialog uses more of a large screen** — The dialog stopped at 1024 px wide however large the screen, and its left column was fixed at 320 px. So preset names were cut off, and every filament row wrapped its label and its "hidden / Show all" note, while the process-settings panel beside it sat half empty. The dialog now widens to 1280 px on wide screens and 1536 px on very wide ones, and the left column takes two fifths of it instead of a fixed width. Below 1024 px it is still a single column.
- **Leftover Web Push code and the unused `pywebpush` dependency are gone (#3171)** — Browser push notifications were removed earlier, but the service worker still carried their push handlers and `pywebpush` was still installed, pulling in `py-vapid` and `http_ece`. Both are removed. `aiohttp` stays, now listed as the direct dependency it is for external cameras, with the same version floor.
- **A STEP preview says it is converting, and for how long (#2976)** — A large STEP export can take over a minute to turn into a mesh in the browser (a 25 MB FreeCAD file took about 75 seconds; coarser meshing settings do not help, the time goes into reading the file), and for all that time the preview showed only a small spinner, which looked like a preview that never opens. STEP loads now show **Converting STEP model… N s** with a running counter and a note that large files can take a minute or more, in all 15 languages. Other formats load in a moment and keep the plain spinner.
- **The schema no longer contains a cycle that `metadata.sorted_tables` cannot sort** — `print_archives.library_file_id` → `library_files.folder_id` → `library_folders.archive_id` → back to `print_archives`: three nullable `SET NULL` links, each reasonable alone, that together made a loop SQLAlchemy answered with a sort it could not make and a warning on every backup and every restore. The order it returned could place a child before its parent, which is what once imported `library_files` ahead of `library_folders` and killed a restore on a foreign key violation — the restore has since stopped depending on that order by stripping every foreign key before importing and adding them back afterwards, but the backup export sorts the same way, and the warning ends with "may raise an error in a future release", which would break backup and restore on the same upgrade. One edge of the loop is now marked `use_alter`, which removes it from the sort graph without removing the constraint from the database: PostgreSQL emits it as `ALTER TABLE ... ADD CONSTRAINT`, as it already did for every constraint on those three tables, and SQLite — which reports no ALTER support — inlines it into `CREATE TABLE` exactly as before, so `ON DELETE SET NULL` keeps working on both. `use_alter` requires a name, so new databases carry `fk_library_folders_archive_id` where existing ones keep PostgreSQL's generated `library_folders_archive_id_fkey`; nothing refers to either by name. Pinned by a test that fails if a new foreign key closes a loop again, and by one asserting the key still exists on SQLite — if that inlining ever stopped, deleting an archive would leave a dangling `library_folders.archive_id` instead of nulling it, silently.
- **The MakerWorld integration is now a model-provider package, so a second model site is an implementation rather than a second copy of the feature ([PR #2845](https://github.com/maziggy/bambuddy/pull/2845) by @pascalheidmann)** — `services/makerworld.py` was a single 635-line module that owned everything from URL parsing to the presigned S3 download, and the routes called it by name, so Thingiverse or Printables would have meant a second route file beside the first. It is now an interface: a `ModelProvider` descriptor carrying identity, host patterns, the credentials it needs, its library folder, its permissions and its SSRF allowlists, plus a per-request `ProviderService` that does the transport — with MakerWorld as the first implementation and a registry that hands a pasted URL to whichever provider claims it. Nothing about the MakerWorld flow changes: the endpoints and their request and response shapes are unchanged, the new `source_type` field defaults to `makerworld` so existing callers send exactly what they sent before, and the transport moved verbatim — thirteen of the eighteen functions are byte-identical by AST comparison, including the CDN allowlist, the 200 MB download cap and the certifi-pinned TLS context that keeps S3 downloads working on Windows. Four things did change. Bambu Cloud credential storage moved out of `routes/cloud.py` into `services/bambu_cloud_credentials.py`, because a feature package that has to import a route module to read a stored token is a dependency pointing the wrong way; the cloud endpoints themselves did not move. An import naming a provider that is not registered is now refused before anything happens, where it used to create the destination folder on its way to failing. "Your Bambu sign-in expired" is now an explicit flag the provider sets rather than something inferred from whether an error message happened to be present, so a network failure or a rate limit can no longer be reported to you as an expired sign-in. And each request is authorised against the permission of the provider it actually named instead of MakerWorld's — latent while MakerWorld is the only one registered, wrong on the day it is not.
- **Updating now repairs a service file that was written before the `--loop asyncio` pin existed (#3001)** — `install.sh` has pinned the loop since 2026-07-05 (#1896), but nothing has ever rewritten an *existing* service file: `install/update.sh` does a `git reset --hard`, a pip install, a frontend build and a restart, and never touches the unit. uvloop has been in every native venv since `uvicorn[standard]` entered `requirements.txt` on 2025-11-28, and uvicorn's `--loop auto` prefers it, so every native install created in that seven-month window has been running on uvloop ever since and no amount of updating has changed that. It cost them every RTSP camera on 1.2.5.4, and before that it left them exposed to a Virtual Printer FTP upload being silently truncated into a corrupt `.gcode.3mf` — which is why #1896 shipped a ZIP-validation backstop alongside the pin, since the pin could never reach the installs that already existed. Both update scripts now add the missing flag themselves: `update.sh` to the systemd unit, `update_macos.sh` to the launchd plist, in each case while the service is stopped so the repair takes effect on the same restart. Only the one flag is ever inserted — a hand-edited port, extra hardening, `ExecStartPre` lines and everything else stay byte-identical, and the file is copied to a timestamped backup first. Anything that is not a single-line unit invoking uvicorn directly is described rather than edited: a wrapper script, an `ExecStart` continued across lines, several `ExecStart` lines, a unit that is not writable, or a service carrying systemd drop-ins, since a drop-in may be what defines `ExecStart` and editing the fragment would then change nothing while reporting success. A loop pinned deliberately is also left alone — someone who wrote `--loop uvloop` on purpose gets no argument, only the startup warning. The check reads the *effective* `ExecStart` from systemd rather than the file, so a drop-in that already pins the loop counts and the repair is idempotent.
- **Bambuddy now says so at startup when it is running on uvloop (#3001)** — every unit file this project ships pins `--loop asyncio`, added for #1896 because uvloop's SSL layer can drop buffered data and truncate a Virtual Printer FTP upload into a corrupt `.gcode.3mf` that is acked `226` and forwarded to a printer. Two populations run a unit nobody here wrote and therefore have no such pin: the Proxmox VE Helper-Scripts LXC, which composes its own `ExecStart`, and native installs created before that fix landed on 2026-07-05, which never gained the flag because `install/update.sh` does not rewrite unit files. Neither had any way to know. #3001 only surfaced them because losing every camera at once is loud; a truncated upload is silent, and there is nothing to notice until a print fails from a file that was corrupt on arrival. Startup now logs one WARNING naming the loop, the risk and the exact flag to add. It is a warning and not a refusal: a server that answers requests beats a purist one that will not boot, and by the time any application code runs uvicorn has already chosen its loop. The check asks the running loop what it is rather than whether uvloop imports — uvloop is a hard dependency here, since `requirements.txt` pins `uvicorn[standard]`, so its presence says nothing about what is in use — and it matches on the module name so that asking the question never imports uvloop on a host that lacks it.
- **The spool form is wider, and its colour, weight and cost fields have their own tab** — The Printers tab is a model list beside a detail pane, which needs the room. Colour, spool weights, price, category and storage location move out of the bottom of a long scroll into a **Color & Cost** tab, laid out in two columns rather than one short field per row. Filament identity and the slicer preset stay on the first tab.
- **Home Assistant sensors moved out of the Smart Plugs settings tab into their own (#2824)** — Sensors are things you read and plugs are things you switch, and with storage-location bindings joining the printer ones the two no longer belong on one page. Both now live under **Settings → Sensors**; nothing about the printer bindings themselves changed, only where they are. The template that carried the printer alert is renamed from "Home Assistant Sensor Alert" to "Printer Sensor Alert", along with the toggle and badge labels that name it, because once a storage-location alert existed beside it the old name no longer said which one it was; the rename only touches templates still holding the old default name, so one that was edited keeps whatever it was called. A battery-class printer sensor now draws a battery icon instead of the generic gauge — the printer map never had an entry for it and the shared one does, which reads as the omission it was rather than a choice worth preserving.
- **Storage locations sort the way they are named (#2824)** — The locations list was ordered by `ORDER BY name`, which puts "Drybox 10" between "Drybox 1" and "Drybox 2". It is now sorted on the numbers inside the name, so a rack numbered past nine reads in rack order everywhere the list appears.
- **The Watchtower we recommend for daily builds is the maintained fork (#2917, reported by @CamelT0E)** — The daily-build instructions in the README, on Docker Hub and in every daily prerelease pointed at containrrr.dev/watchtower. That project has been archived and read-only since December 2025 and its last release, v1.7.1, is from November 2023, so anyone following the recommendation was being handed a container with Docker socket access that had not received a fix in over two years. Development continues in Nicholas Fedor's fork, which ships as `nickfedor/watchtower` and released v1.21.0 this month. All four references now point at watchtower.nickfedor.com and name the image, including the release-notes template in `docker-publish-daily-beta.sh` that produced the screenshot in the report — the READMEs alone would have left every future daily prerelease repeating the dead link. Existing images keep working; only the recommendation changed.
- **The Windows installer build is split in two so a signing request can wait for a human (SignPath Foundation)** — Release tags are Authenticode-signed through the SignPath Foundation OSS programme, and the production certificate does not sign on demand the way the self-signed test certificate does: every request has to be approved by hand in the SignPath UI, because the Foundation verifies what is being signed and which build it came from. The submitting action waits for that approval with a default timeout of 600 seconds, which is ample when the test policy approves automatically in seconds and far too short once the wait is a person noticing a tag went out. A tag pushed at night would have failed the run ten minutes later with the installer already compiled and thrown away. The compile now ends in its own job that uploads the unsigned artifact and stops; a second job downloads it, signs it, and does the release-facing work, with the wait raised to an hour. Because the artifact is uploaded before the wait begins and is addressed by id, a missed approval window is recovered by re-running the second job alone rather than rebuilding the installer — which is the reason to separate them rather than simply raise the timeout in place. The second job runs for unsigned builds too, so the daily prereleases that are deliberately left unsigned to preserve the signing quota keep going out through exactly one set of alias, artifact and release steps. The property that matters is unchanged and now recorded next to the steps that depend on it: none of the alias, upload or release-attach steps carry `always()`, so GitHub skips all three when signing fails or times out, and an unsigned `.exe` cannot reach a release. Nothing about the signed output changes, and the restructure behaves identically under the test policy — the request simply completes immediately instead of waiting — so it can be proven green before the production certificate arrives.
- **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable.
- **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
### Fixed
- **PostgreSQL installs no longer warn at every start that the connection pool may exceed the server** — The built-in pool allowed up to 100 connections (`pool_size` 20 + `max_overflow` 80), but a stock PostgreSQL allows 97 (`max_connections` 100 minus 3 reserved for superusers). So every install that hadn't set `DB_POOL_SIZE` / `DB_MAX_OVERFLOW` logged `DB pool may exceed what PostgreSQL allows` at startup, and under heavy load could hit `TooManyConnectionsError`. The default `max_overflow` is now 60, an 80-connection ceiling that fits. Installs that set their own values are unaffected, and large farms can still raise both, along with the server's `max_connections`.
@@ -52,6 +83,7 @@ All notable changes to Bambuddy will be documented in this file.
- **In Spoolman mode, each spool's own size is used, not its filament's (#3194, reported by @worried-networking)** — Spoolman stores how much filament a full spool holds on the spool (**Initial Weight**) and uses the filament's **Weight** only when the spool has none, so one filament can have spools of different sizes. Bambuddy read only the filament, so a 250 g spool of a 1000 g filament showed as 1000 g with the wrong fill level, **Sync Weights from AMS** and AMS-percentage usage tracking charged it four times the real grams, and the AMS hover card's Spoolman fill level was off the same way. All of these, the weigh action and the SpoolBuddy scale now use the spool's own size. Creating a spool writes its **Label Weight** to the spool's Initial Weight, so a 250 g spool of a 1000 g catalogue filament is recorded as 250 g. Editing the Label Weight changes only that spool: it used to change the filament's weight while Spoolman kept the spool's old size, so the two disagreed from then on, and on a filament shared with other spools it linked this spool to a new duplicate filament instead. The spool form's **Cost per kg** is stored as Spoolman's spool **Price**, which Spoolman treats as the price of the whole spool; it is now converted at the spool's size both ways, and print cost divides a spool's own price by the spool's size. A catalogue price on the filament is still divided by the filament's weight. For 1000 g spools nothing changes. Saving a spool without changing its size or price no longer writes either, so a 250.7 g spool stays 250.7 g. Editing a spool that has no weight in Spoolman at all, on the spool or its filament, failed with an error; it now saves, and a Label Weight saved from the spool form becomes the spool's size.
- **In Spoolman mode, weighing a spool now uses the vendor's empty spool weight (#3195, reported by @worried-networking)** — Spoolman finds a spool's empty weight in three places: the spool's own **Spool Weight**, then the filament's, then the vendor's **Empty Spool Weight**. Its own `/measure` endpoint uses that order. Bambuddy stopped after the filament and used 250 g instead of the vendor's value. So a spool whose tare was set only on its vendor weighed against the wrong core: a Sunlu spool (211.7 g) at 1177 g on the scale was saved as 927 g remaining instead of 965.3 g. The same error was in three places, and each is fixed: the weigh action in the inventory, the SpoolBuddy scale, and the **Empty Spool Weight** shown in the spool form and used for gross weight in the list. All three now share one lookup, so they can't drift apart again. The SpoolBuddy "no spool weight set, using 250 g" warning now appears only when the spool, the filament and the vendor all have no weight. The spool form still sends the empty weight to Spoolman only when you change it, so opening and saving a spool does not copy the vendor's value onto it. Changing a filament's spool weight with **Keep old weight for existing spools** now keeps a vendor-inherited tare too: when the filament had no weight of its own, the spools that inherited the vendor's value get that value, not the new filament weight.
- **A P1P, P1S or X1E added by discovery is saved as the right printer** — Bambu printers announce themselves with an internal code, and the table that turns it into a model name had the P-series codes shifted: a discovered P1S was saved as a P1P, a P1P as a P1S, and an X1E as a P2S. The same mix-up sat in the backend: a file sliced for a P1P or P1S could say it was sliced for an X1C or X1, the X1E's code checked the P2S firmware line, and the P2S's code was missing. All of them now agree with what the printers and 3MF files actually report. Printers saved under a raw code by an older version also get the right ethernet, rod-type and storage handling. A printer that was already saved with the wrong model keeps it: pick the right one under **Model** in **Edit** from the printer card's menu.
- **Generate Thumbnails works for users who can edit only their own files, and external folder scans no longer wait on PDF thumbnails** — The toolbar's **Generate Thumbnails** button and the file menu's **Generate Thumbnail** entry were offered to users who may edit only their own library files, but the server refused them. For those users both now cover the files they uploaded themselves; users who may edit all files still get everyone's. Scanning an external folder rendered each new PDF's thumbnail before the scan finished, which on a Raspberry Pi with a share full of PDFs kept the scan waiting for a long time. PDF thumbnails are now made in the background after the scan, like STL thumbnails already were.
- **The Low Filament notification now fires (#2913, reported and contributed by @ojimpo in #2940)** — The event had a toggle on every notification provider, but nothing in Bambuddy ever sent it, so switching it on did nothing. It now alerts when a spool assigned to an AMS, AMS-HT or external slot drops below its low-stock threshold. That is the same global percentage (default 20 %) and per-spool override that drive the Low Stock count on the Inventory page, so the alert and the count agree. Remaining filament comes from the spool's weight, not the AMS remain percentage, which can be far off. It works with the built-in inventory and with Spoolman; with Spoolman only the global threshold applies. Each spool alerts once per slot and can alert again after it goes back above the threshold, for example when a fresh one is loaded. Archived spools and slots with no assigned spool never alert. The check runs every 30 seconds and does nothing unless a provider has the event switched on. `{color}` is now filled in for custom templates. If you switched Low Filament on in the past, expect one alert for each assigned spool that is already low after updating, and again after each restart while it stays low.
- **A live camera view no longer stops for good after about half an hour on X1, H2 and P2 printers** — These printers' camera streams come over RTSP, and the printer ends each session after a while; a stock X1 Carbon ends every one after exactly a minute. Bambuddy reconnects straight away, so viewers never notice, but every reconnect counted against a limit of 30 for the life of the stream. Half an hour into a print the camera stopped and did not come back until the page was reopened. The limit now counts failed attempts in a row, and a session that delivered video resets it. A printer that refuses the camera for a while, for example because another app is on it, used to be given up on after 30 attempts in nine seconds. Bambuddy now waits longer between failed attempts, up to five seconds each, so it gets about two minutes. External cameras had the same problem with a limit of three: a camera server that ends its sessions now and then stopped the stream on the fourth drop. External streams now always reconnect after a session that delivered video. Found while investigating #3189.
- **A slot that reads empty for a moment no longer loses its spool assignment (#3186, reported by @Sawtaytoes)** — An idle X1 Carbon sent one status update that showed a whole AMS unit as empty, with no colour or material in any slot, and Bambuddy deleted all four spool assignments on it at once. The spools never moved, and the next update reported them again. Nothing brought the assignments back, and for a non-RFID spool the assignment is the only record of which spool is in the slot. The #3100 fix covered a blank slot the AMS still reported as occupied, but not one briefly reported as empty. A slot that looks empty, or that drops out of the AMS data, now keeps its assignment for two minutes and loses it only if it is still empty then. Bambuddy checks again by itself when the two minutes are up, so a spool that was really taken out does not wait for the next AMS change. A different spool the AMS can identify, by its RFID tag or its colour and material, still releases the old assignment immediately. A spool it cannot read that goes in during those two minutes releases it once they are up, rather than inheriting the old spool's assignment. Assigning a spool to the slot yourself cancels the wait. Spoolman mode's slot links follow the same rules.
@@ -67,25 +99,10 @@ All notable changes to Bambuddy will be documented in this file.
- **Saving an empty value for a number setting through the API no longer breaks the Settings page** — A direct API call, script or Home Assistant `rest_command` could send `null` for a number setting such as the keep-warm bed temperature. Bambuddy stored it as the text "None", and from then on every load of the settings failed with an error, for every setting on the page, until the database was fixed by hand. A `null` for a setting that can't be empty, whether a number or an on/off switch, is now refused with a message that names the setting, and nothing from that request is saved. A setting already stored that way now loads as its default, so an affected install recovers on its own.
- **"Reset usage to 0" on a Spoolman spool set it back to full (#2906, reported and contributed by @ojimpo in #2939)** — The reset set Spoolman's `used_weight` to 0. Spoolman calculates the remaining weight as initial minus used, so the spool jumped back to full and its measured remaining weight was lost: a roll with 737 g left read 1000 g, and later prints were charged against filament that wasn't there. The confirmation dialog promised the opposite, that the spool and its remaining weight would not change. The reset now stores the used weight at that moment in a Bambuddy field in the spool's custom fields (`bambu_weight_used_baseline`), the same way it has always worked without Spoolman, and Bambuddy subtracts it when it shows the consumed counter. Spoolman's initial, used and remaining weights are left untouched, and so is every other custom field. The value is stored as text because Spoolman sets a field's type on first use and never lets it change; a number would have been rejected on every install. The bulk reset works the same way. Spools already reset by the old version stay as they are: their remaining weight was overwritten in Spoolman, so they need re-weighing.
- **Filament printed from the external spool was deducted from an AMS spool (#3166)** — The firmware doesn't accept the external spool in a print command's `ams_mapping` list. So BambuStudio and Bambuddy's own dispatch both write it there as -1, the same value as an unused slot, and put the real target in `ams_mapping2`. Bambuddy read only the first list, so an external-spool print looked unmapped, and usage tracking fell back to guessing by position: the first loaded AMS slot. The reporter's H2D charged every external-spool print that way, including 225 g of ABS taken from the PLA spool in AMS slot 1; the same was reported on H2S and P2S. The external spool is now read from `ams_mapping2` (left or right on dual-nozzle printers). A slot the mapping marks as unused is no longer given a tray by position. When a mapping names no tray at all, the tray the printer reports decides instead. The last change also stops an unused slot from taking over the external spool that another slot really used and dropping that slot's weight (#2880).
- **Upgrading from Bambuddy 0.2.4.0 or older no longer crashes at startup** — Since 1.2.5.4, startup first converts old failure-reason labels to the new vocabulary (#2974), in both the archives and the print log. A database from before the print log had a failure-reason column (#1378) reached that conversion before the migration that adds the column, and Bambuddy stopped with `no such column: failure_reason` instead of starting. The conversion now skips a table that doesn't have the column yet; it can't hold old labels anyway. On SQLite it also rebuilds the archive search index before converting archives, because archives created before that index existed were never added to it, and updating one of them failed with `database disk image is malformed`. It only rebuilds when there is something to convert, and it skips both tables entirely when nothing needs converting, so a normal startup does less work than before.
- **A connection check that overruns in the support bundle now says which step hung, and keeps what finished (#3164)** — The support bundle gives each printer's connection diagnostic 15 seconds and used to discard the whole result when it overran, recording only `timed_out`. A 14-printer farm's bundle came back with that marker on every printer and nothing else, so it could not show which check was slow or whether the printers were reachable at all. The diagnostic now records each check as it finishes and the step it is on; a timed-out entry carries `stalled_in` (the step that was still running), `elapsed_s`, and the checks that completed before it.
- **Two inventory notification toggles could never be turned on, so stock alerts have never been able to fire (#2945, reported by @ojimpo; regression tests contributed by @ojimpo in #2956)** — `on_stock_reorder_alert` and `on_stock_break_alert` exist as columns on a notification provider, have their own templates, and `notification_service` looks providers up under exactly those names before sending. The whole UI is there too: a toggle in Add/Edit Notification, a badge on the provider card, the field in the API client's types, and tests for all of it. The one thing missing was the schema. `NotificationProviderCreate`/`Update` never declared either field, and Pydantic drops what it does not declare, so every request that carried them came back `200 OK` with the row unchanged — and `_provider_to_dict`, which is a hand-maintained field-by-field map, never returned them either, so the toggle read back off no matter what the database held. Nothing errored anywhere along that path. Both directions are wired now, and the round-trip tests that already covered the Home Assistant toggles cover these too, because the failure is structural rather than particular to one field: any column missing from those two maps is invisible to a test that builds providers through the ORM, and only a create-then-re-read through the route catches it. This makes the setting stick and report itself honestly; what actually sends the alerts is the #2955 entry above. The tests no longer depend on anyone remembering the next field, either. They now read the list of provider fields from the database model itself, so a toggle or setting added later is checked from the day its column exists, with no new test to write. It must be accepted when a provider is created and when it is edited, and every toggle must survive a save and read back from both the single-provider and the list endpoint. Each toggle is set to the opposite of its default for that check, because nine of them default to on and a test that saves "on" and reads back "on" would pass even if the value were silently dropped.
### Security
- **Bumped `js-yaml` to 5.4.2 for a merge-key denial-of-service advisory** — In versions up to 5.4.0, the `maxTotalMergeKeys` limit does not count empty mappings. A small YAML document that merges a long list of `{}` many times (`<<: *arr`) can keep the CPU busy for seconds per few hundred kilobytes without ever reaching the limit. It only applies when merge keys are enabled, which means the YAML 1.1 schema. The fix counts each merged mapping towards the budget. **No running Bambuddy install was exposed, and the issue was not reachable at build time either.** `js-yaml` is a development-only transitive dependency, pulled in by `eslint` through `@eslint/eslintrc`, and it is not in the shipped image. `@eslint/eslintrc` only parses legacy `.eslintrc.yaml`/`.eslintrc.yml` files, and this repository has none. Linting uses the flat `frontend/eslint.config.js`, so no YAML is loaded at all. The existing `overrides` pin in `frontend/package.json` moves from `^5.2.3` to `^5.4.1`, so a regenerated lockfile cannot resolve back below the fix. The lockfile changes in one entry only, with nothing added or removed, and the shipped bundle does not change. Verified with eslint, typecheck, the production build, and the full frontend suite (4022 tests across 297 files). `npm audit` reports no `js-yaml` findings.
- **Bumped `brace-expansion` to 5.0.12 for three denial-of-service advisories** — Two high-severity issues, fixed in 5.0.10 and 5.0.11, let deeply nested brace groups exhaust the native stack. Roughly 3,100 levels of `{{{...a,b...}}}`, about 6 KB of input, crashed the process: once through the recursive comma splitter and once through the expander itself. The medium-severity third, fixed in 5.0.12, is the bash `{a},b}` quirk. The parser handles it by rewriting the string and rescanning once per trailing `}`, so the cost grows quadratically, and 128 KB of input blocked the event loop for 27 seconds. 5.0.12 makes the comma splitter iterative and caps nesting depth and rewrite passes at 1,000 each. Input past either cap is kept as plain text rather than throwing, the same way the existing `max` and `maxLength` limits work. **No running Bambuddy install was exposed, and none of the issues was reachable at build time either.** `brace-expansion` is a development-only transitive dependency, pulled in by `eslint` through `minimatch`, and it is not in the shipped image. The only patterns it ever expands are the fixed globs in `frontend/eslint.config.js`. Before the bump, the output of 5.0.9 and 5.0.12 was compared on every glob in that file plus ranges, nested sets, escaped braces and the `{a},b}` quirk, and it was identical. The same comparison confirmed the fixes: 5,000 levels of nesting overflow the stack on 5.0.9 and expand cleanly on 5.0.12, and a 20,000-brace rewrite input drops from 3 seconds to 13 ms. The existing `overrides` pin in `frontend/package.json` moves from `^5.0.9` to `^5.0.12`, because `minimatch` still asks for only `^5.0.2`. The lockfile changes in one entry only, `balanced-match` is untouched, nothing is added or removed, and the shipped bundle does not change. Verified with eslint, typecheck, the production build and the full frontend suite (4022 tests across 297 files). `npm audit` reports zero vulnerabilities with and without dev dependencies.
## [1.2.5.6] - 2026-09-24
### Added
- **Swedish (sv) is now a supported interface language (#3052, requested and contributed by @AntonPalmqvist in [PR #3062](https://github.com/maziggy/bambuddy/pull/3062))** — Adds `sv` as the fifteenth locale, listed as "Svenska" in the language picker. It arrives in parity with the reference locale rather than as a file to be finished afterwards: all 6302 leaves present, structure and key order matching `en.ts`, and every `{{placeholder}}` set intact, which is what the parity gate checks before a locale is allowed to ship. 156 of those leaves are identical to English, and each is enumerated by value in a 74-entry `SV_COGNATES` allow-list in `check-i18n-parity.mjs` — the same shape the other thirteen locales use, so the exemption is an enumerated translator decision rather than a blanket skip for the language. They are the strings with nothing in them to translate (`{{weight}}g`, `Hex: #{{hex}}`, `{{filament}} @ {{temp}}°C`), the product names Bambu and Orca ship untranslated, and the technical UI vocabulary Swedish takes verbatim from English — filament, status, extruder, timelapse, firmware, proxy, pipeline, diameter. Parity green at 6302 leaves across all 15 locales.
### Changed
- **The schema no longer contains a cycle that `metadata.sorted_tables` cannot sort** — `print_archives.library_file_id` → `library_files.folder_id` → `library_folders.archive_id` → back to `print_archives`: three nullable `SET NULL` links, each reasonable alone, that together made a loop SQLAlchemy answered with a sort it could not make and a warning on every backup and every restore. The order it returned could place a child before its parent, which is what once imported `library_files` ahead of `library_folders` and killed a restore on a foreign key violation — the restore has since stopped depending on that order by stripping every foreign key before importing and adding them back afterwards, but the backup export sorts the same way, and the warning ends with "may raise an error in a future release", which would break backup and restore on the same upgrade. One edge of the loop is now marked `use_alter`, which removes it from the sort graph without removing the constraint from the database: PostgreSQL emits it as `ALTER TABLE ... ADD CONSTRAINT`, as it already did for every constraint on those three tables, and SQLite — which reports no ALTER support — inlines it into `CREATE TABLE` exactly as before, so `ON DELETE SET NULL` keeps working on both. `use_alter` requires a name, so new databases carry `fk_library_folders_archive_id` where existing ones keep PostgreSQL's generated `library_folders_archive_id_fkey`; nothing refers to either by name. Pinned by a test that fails if a new foreign key closes a loop again, and by one asserting the key still exists on SQLite — if that inlining ever stopped, deleting an archive would leave a dangling `library_folders.archive_id` instead of nulling it, silently.
- **The MakerWorld integration is now a model-provider package, so a second model site is an implementation rather than a second copy of the feature ([PR #2845](https://github.com/maziggy/bambuddy/pull/2845) by @pascalheidmann)** — `services/makerworld.py` was a single 635-line module that owned everything from URL parsing to the presigned S3 download, and the routes called it by name, so Thingiverse or Printables would have meant a second route file beside the first. It is now an interface: a `ModelProvider` descriptor carrying identity, host patterns, the credentials it needs, its library folder, its permissions and its SSRF allowlists, plus a per-request `ProviderService` that does the transport — with MakerWorld as the first implementation and a registry that hands a pasted URL to whichever provider claims it. Nothing about the MakerWorld flow changes: the endpoints and their request and response shapes are unchanged, the new `source_type` field defaults to `makerworld` so existing callers send exactly what they sent before, and the transport moved verbatim — thirteen of the eighteen functions are byte-identical by AST comparison, including the CDN allowlist, the 200 MB download cap and the certifi-pinned TLS context that keeps S3 downloads working on Windows. Four things did change. Bambu Cloud credential storage moved out of `routes/cloud.py` into `services/bambu_cloud_credentials.py`, because a feature package that has to import a route module to read a stored token is a dependency pointing the wrong way; the cloud endpoints themselves did not move. An import naming a provider that is not registered is now refused before anything happens, where it used to create the destination folder on its way to failing. "Your Bambu sign-in expired" is now an explicit flag the provider sets rather than something inferred from whether an error message happened to be present, so a network failure or a rate limit can no longer be reported to you as an expired sign-in. And each request is authorised against the permission of the provider it actually named instead of MakerWorld's — latent while MakerWorld is the only one registered, wrong on the day it is not.
- **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable.
### Fixed
- **PDF previews in the File Manager failed on any browser older than Chrome 145 or Firefox 144 (#2976)** — Every PDF showed "This file cannot be previewed." pdf.js 6 calls `Map.prototype.getOrInsertComputed` and other recent APIs directly, and the preview loaded its standard build, which assumes them. It now loads pdf.js's legacy build, which bundles polyfills for both the page and the worker. The worker is also bundled through Vite (`?worker&url`) instead of being copied as-is: the copied file kept a class static block that Safari 16.0-16.3 cannot parse, and `build.target` now lowers it like the rest of the app. The Safari 16 baseline check missed that file because it only scanned `.js` output; it now scans `.mjs` too. Checked in a real Chromium without the API, under the app's own Content-Security-Policy: a two-page text PDF renders, pages, and reopens.
- **The concurrent-dispatch tests no longer depend on machine speed to prove the scheduler dispatches in parallel** — `tests/unit/test_scheduler_concurrent_dispatch.py` failed on CI and passed locally on a commit that touched nothing but a text file, and it had two independent reasons to. The first: the fixtures built their farm on `sqlite+aiosqlite:///:memory:`, and SQLAlchemy backs an in-memory SQLite with a `StaticPool` — one DBAPI connection handed to every session, with nothing keeping them apart. That was harmless while `check_queue` awaited its uploads inline, because only one session was ever live at a time. Under the refillable upload pool (#2602) the uploads run as concurrent background tasks with a session each, so their transactions interleave on that single connection: a sibling session's `close()` rolls back another's flushed-but-uncommitted UPDATE, which is how six queue items were each logged as `Status set to 'printing'` and four of them read back as `pending`. The fixtures now put the database in the test's own `tmp_path` and open it through the application's own connect listener, so each session gets its own connection and the file is opened the way the running system opens one — WAL, `synchronous = NORMAL`, a 15 s busy timeout — rather than with SQLite's defaults, which spend an fsync per commit and lock the whole file. The second reason was underneath the first and would have outlived it: `peak == 6` means the sixth dispatch reached its upload before the first one finished, and each dispatch runs a preamble of database work first, so the assertion was really a race between that spread and a fixed 0.15 s sleep. The spread is ~14 ms on a workstation and passed 150 ms on a CI runner, which recorded a peak of 4 out of 6 for a scheduler that was dispatching all six correctly. The uploads now hold until every upload the pass launched has arrived — `scheduler._inflight` is filled synchronously at launch, so its length is the batch size — which states the property directly, with no time in it, and ends sooner than the sleep it replaces. Reproduced both ways before and after: with one printer's preamble delayed by a second, the old recorder reports exactly the "high-water mark was 4" CI saw and the new one passes. Nothing in the scheduler changes; the concurrency under test was correct throughout.
- **An AMS that reports no humidity percentage no longer shows the drop index as one (#3140, reported by @Sawtaytoes)** — Bambu sends two humidity fields that are not the same quantity: `humidity_raw` is relative humidity in percent, and `humidity` is the 1-5 drop index, which runs the other way — a high index means dry where a high percentage means wet. Bambuddy used the index whenever no percentage arrived, so a unit sending only the index rendered as "2%" in the green band while being the second-wettest of the five steps, charted an average of index values as a percentage, and could never cross a humidity alarm or auto-drying threshold, since no index reaches one. Such a unit now reports no humidity at all: the card hides the water-drop indicator, the history chart leaves a gap, and the alarm and auto-drying skip the unit instead of reading it as permanently bone dry. Temperature is recorded and alarmed on as before. No supported printer is known to be affected — the report came from an install running X1Plus, which Bambuddy does not support — and printers that send a percentage keep it, including two readings that previously fell through to the index because they would not parse as a whole number: one with a decimal point, and `38.0`. A unit that sends the index and no percentage now says so once in the log, with its firmware versions requested, so a supported printer that turns out to do this is visible rather than silently blank. Three smaller faults in the same paths went with it: a humidity of exactly 0% was stored as NULL when the firmware sent it as a number, a history window averaging exactly 0 reported no average at all while the minimum and maximum beside it reported 0, and a `humidity_raw` that was not a number at all aborted the whole recording pass for every printer, not just the one that sent it.
- **Two Bambuddy instances could not have their CA certificates trusted at the same time (#3014, reported by @Steven-Pierce)** — a slicer holding the CA of two Bambuddy installs failed to connect to one of them, with the same generic `code=-1` an install whose CA was never imported gives. Each CA worked on its own; together, one stopped. Every install signed its virtual printers with an authority named exactly `CN=Virtual Printer CA`, and a slicer's trust store is a flat list of certificates that OpenSSL resolves by subject name: it takes the first authority whose name matches the certificate in front of it and fails the chain when that authority turns out not to have signed it, rather than trying the next match. Whichever of the two landed second in the file therefore lost, decided by nothing more than the order they were appended in. A newly generated CA now carries a per-install suffix taken from its own key — `CN=Virtual Printer CA D55808BE` — and publishes that key identifier, which the printer certificate then points back at, so the two are told apart by name and each chain resolves to the authority that really signed it. Nothing has to be re-imported on upgrade: an install that already has a CA keeps it untouched, and the printer certificates signed by it keep exactly the shape they have today. The collision goes away as soon as one of the two CAs is newer than this release — a second install set up after upgrading, or an existing one whose `bbl_ca.crt` and `bbl_ca.key` are deleted from `virtual_printer/certs/` so a fresh CA is generated, which does mean importing that one into the slicer again.
- **ntfy per-event priorities were set, stored, and never sent (#3139, reported by @Thomansky)** — a per-event priority (#990) never reached ntfy: every notification arrived at the server's default, whatever the dialog showed. The two halves of the feature disagreed about the key. The dialog builds its rows from the provider's event toggles and stores the map under those names — `on_print_complete`, `on_print_failed` — while every sender is called with the bare event name, `print_complete`, so the lookup missed for all 18 events the dialog offers. It looked healthy from both ends: the stored config held exactly the priorities that were set, and the tests were green because they called the ntfy sender directly with the prefixed name, the one spelling the running system never produces. The lookup now accepts either spelling, so existing configurations keep working and nothing has to be migrated. The tests use the bare form, and one of them now runs from a finished print through to the outgoing request, which is the assertion that would have caught this. On upgrade, priorities that have been sitting in the database inertly start being honoured, so an event mapped to **Min** or **Low** will now arrive quieter than it did yesterday — that is the setting taking effect, not alerts going missing. The daily digest is unchanged and still goes out at the server default: it summarises several events at once, and the dialog offers no priority for it.
@@ -93,7 +110,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Slicing a plate of many copies of one part could take the server down (#3135, reported by @TheUltimateC0der)** — the reporter sliced a plate of 25 small Gridfinity bins and Bambuddy was OOM-killed straight after the slice finished, taking the slice job with it. After every slice Bambuddy draws the plate thumbnail itself, because the slicer's command-line mode leaves it out, and it read the model with trimesh. trimesh mishandles the layout Bambu Studio and OrcaSlicer save projects in, where each copy is a reference into a shared model file: it read that file again for every reference and kept every copy of its triangles, so 25 copies of a part arrived as 625, and 25 bins of 10,000 triangles each became 6.4 million. Drawing that took 54 seconds and 8.4 GB, and it ran on the server's main loop, so the web UI and every printer connection stalled for as long. A project whose model file holds several parts had a second symptom: each placed part pulled in every other part from the same file, so its thumbnail drew parts in places they were never put. The plate is now read directly, each model file once and each part once however many times it is placed, and a large plate is simplified per part to a fixed triangle budget before its copies are laid out. The reporter's plate now renders in about 2.4 seconds at under 400 MB, and the heaviest real projects measured went from 48 seconds and 9.3 GB to 20 seconds and 0.8 GB. The thumbnail is drawn off the main loop, and a plate still too large after simplifying is left without one instead of risking the server.
- **Moving a queued job from "Any P2S" to one P2S threw away its filament colour (#3133, reported by @bgrr74)** — a job queued for any printer of a model can print in a different filament from the one it was sliced with: brown in the 3MF, Bone White on the spool. Editing that job and switching it to a specific printer of the same model reset the choice, and the job went back to brown. The print dialog cleared overrides whenever the target model changed, and "Specific Printer" empties the target model, so the switch counted as a change. Clearing would not have been the whole problem anyway: in specific-printer mode the dialog matched trays against the 3MF's colours, never sent the override, and left the old one on the queue item, where it contradicted the trays the job would actually use. Now the override is kept through the switch, and the chosen printer's trays are matched against the requested filament, so Bone White picks the Bone White spool, and a printer without one shows the usual colour-mismatch warning for Bone White instead of quietly settling on brown. The job keeps the override on the queue item, so if the dialog could not resolve every tray, the scheduler's match at dispatch looks for the same colour. Moving the job to a different printer model, or picking a different plate, still clears the override, as before. A printer's queue card and badge no longer hide a job assigned to that printer because its override colour is not loaded. That check is for picking among printers of a model, which a job for one printer has already done. The same goes for a virtual printer's force-colour jobs sent to a specific printer, which the card used to hide the same way even though the scheduler dispatched them. Saving a virtual-printer job in the print dialog now keeps the PLA-variant pin (Basic, Matte or Silk) its force-colour overrides carry. Editing one queued for any printer of a model used to strip it.
- **Asking for 25 copies across two printer models queued exactly one (#3101, reported by @Leander-Vh)** — the reporter sliced one project for two printer types, selected both files in the File Manager, set the count and pressed Print. One item printed, the queue was empty behind it, and the Batches tab held nothing. Two features had met badly. Cross-model alternatives (#671) put several sliced files behind one queue slot, and a multi-plate file (#342) moves the run count out of the modal's single Quantity field onto a stepper beside each plate. The cross-model submit reads the field, not the steppers — and on a multi-plate file that field is not rendered at all, so it stayed at its initial 1 with nothing on screen able to change it. The modal read "19 runs in total" directly above a button that queued one. Cross-model no longer borrows the per-plate steppers: such a job picks its plate per candidate, down in the alternatives list, so there is only ever one number to give and the ordinary Quantity field is back. The plate selector above it is gone in that mode for the same reason — its choice never reached the request. It only decided which plate the filament panel described, which is why picking plate 3 for a candidate while plate 1 stayed ticked above it produced overrides computed from a plate the job would not print; that panel now follows the first candidate's own plate. The backend needed nothing for the count itself — it has always created one item per copy and given each its own candidate rows, so the same request now produces 25 jobs, each free to land on whichever of the two models frees up first. It did need a name: a cross-model job carries neither an archive nor a library file, because the candidates are the files, so both branches that name a batch missed and the order the reporter went looking for would have appeared in the Batches tab as "Batch". It is now named after its first candidate, the same file the print dialog names the job after.
- **A print from Bambu Studio archived under the name `plate_1` instead of its own (#3126, reported by @alex-2000-ac-ghetto)** — the reporter's X2D archived Bambu Studio prints with no thumbnail, no filament total and no layer count, while the same project sent from OrcaSlicer archived in full. Two separate things were happening. The first is the known internal-storage limitation: Bambu Studio filed the sliced file on the printer's eMMC (`"url": "brtc://emmc/..."`), which FTPS on port 990 does not serve, and the X2D does this like the H2 series and the P2S. Bambuddy's bounded probe then found a file of exactly that name on the card and downloaded it — but it was an earlier slice of the same project, plate 4, while the running print was plate 1. The guard added in #1204 caught the contradiction and refused it, which is right: archiving plate 4's thumbnail, filament and cost against this print is the swap #2957 removed. What it did next was not. Having rejected the file, it asked `swap_plate_suffix` for a name with the correct plate number and blanked the print's name entirely whenever that came back `None` — but `None` covers a name that carries no `- Plate N` suffix at all, and such a name holds no stale plate number to be wrong about. So a single-plate project lost the name Bambuddy was holding and the row fell through to the gcode_file path, titled `plate_1`. #1204's own premise is consecutive plates *of the same model*, so the project part of a lagging name is right either way; it is now kept as the archive's title, and only a suffix that really does point at the wrong plate is rewritten. Kept as the title and nothing else, though: a name the guard has just watched fetch another plate's 3MF still gets disowned everywhere a file is looked up by name. That includes the `_active_prints` keys, and it matters — the cover endpoint downloads the running print's 3MF under that same name for a thumbnail and offers the bytes to the fallback-recovery path, which checks that a candidate is a readable 3MF but never which plate it holds. Registering the row under the rejected name would have let it be filled in with the exact file this branch had just discarded. The archive is still thumbnail-less — nothing can read that eMMC file — but it carries the print's name again. Separately, the Archives banner and the connection diagnostic named only "H2-series and P2S" as the models that do this, in all 14 languages; an X2D owner reading either was told the explanation did not apply to them. All three now name the X2D too.
- **A print from Bambu Studio archived under the name `plate_1` instead of its own (#3126, reported by @alex-2000-ac-ghetto)** — the reporter's X2D archived Bambu Studio prints with no thumbnail, no filament total and no layer count, while the same project sent from OrcaSlicer archived in full. Two separate things were happening. The first is the known internal-storage limitation: Bambu Studio filed the sliced file on the printer's eMMC (`"url": "brtc://emmc/..."`), which FTPS on port 990 does not serve, and the X2D does this like the H2 series and the P2S. Bambuddy's bounded probe then found a file of exactly that name on the card and downloaded it — but it was an earlier slice of the same project, plate 4, while the running print was plate 1. The guard added in #1204 caught the contradiction and refused it, which is right: archiving plate 4's thumbnail, filament and cost against this print is the swap #2957 removed. What it did next was not. Having rejected the file, it asked `swap_plate_suffix` for a name with the correct plate number and blanked the print's name entirely whenever that came back `None` — but `None` covers a name that carries no `- Plate N` suffix at all, and such a name holds no stale plate number to be wrong about. So a single-plate project lost the name Bambuddy was holding and the row fell through to the gcode_file path, titled `plate_1`. #1204's own premise is consecutive plates *of the same model*, so the project part of a lagging name is right either way; it is now kept as the archive's title, and only a suffix that really does point at the wrong plate is rewritten. Kept as the title and nothing else, though: a name the guard has just watched fetch another plate's 3MF still gets disowned everywhere a file is looked up by name. That includes the `_active_prints` keys, and it matters — the cover endpoint downloads the running print's 3MF under that same name for a thumbnail and offers the bytes to the fallback-recovery path, which checks that a candidate is a readable 3MF but never which plate it holds. Registering the row under the rejected name would have let it be filled in with the exact file this branch had just discarded. The archive is still thumbnail-less — nothing can read that eMMC file — but it carries the print's name again. Separately, the Archives banner and the connection diagnostic named only "H2-series and P2S" as the models that do this, in all 13 languages; an X2D owner reading either was told the explanation did not apply to them. All three now name the X2D too.
- **Linking a tag another spool already carries gave an answer nothing could act on, and a duplicate broke the request outright (#3110, reported by @Niko11111)** — the built-in inventory refused with "Tag UID already linked to another active spool", naming nobody, although the route had just loaded the conflicting spool row and was holding it when it wrote that sentence. Spoolman mode did name the spool, but only inside a different English sentence. Same operation, two answers, neither machine-readable, so a client had to parse prose to learn which spool to look at — and could only do it in one of the two inventory modes. Both now raise one structured 409, `{"code": "tag_already_linked", "message": ..., "spool_id": N, "field": "tag_uid" | "tray_uuid"}`, the shape Bambuddy already answers `insufficient_filament` and `printer_connection_failed` with. The SpoolBuddy kiosk reads it: a refused link showed a flat "Failed to assign spool" and now names the spool holding the tag, so the operator can walk to that spool instead of rescanning one that cannot link. The other half was worse than untidy. Nothing stops two active spools from carrying one tag — neither column has a unique index, `PATCH /inventory/spools/{id}` writes both with no conflict check, and `POST /inventory/spools/bulk` copies a single payload, tag included, into every row it creates — and the conflict lookup read its result with `scalar_one_or_none()`, which raises when two rows come back. That exception escaped the route into the authentication middleware’s fail-closed handler, so the caller was told "Authentication service temporarily unavailable" for a request that had nothing to do with authentication. The lookup is now ordered and takes the first row, the way the by-tag lookup earlier in the same file always has, and a duplicate yields a 409 naming the lowest id.
- **"Clear RFID Tag" was permanently greyed out for a spool linked by its Bambu tray UUID (#3109, reported by @Niko11111)** — the button gated on `tag_uid` alone, and a spool linked by its 32-character tray UUID carries none: Bambuddy splits a stored tag by length, so a 32-char value becomes `tray_uuid` and `tag_uid` stays empty. In Spoolman mode that is every Bambu Lab spool synced from the AMS — on the reporter's instance, 35 of the 39 tagged spools, not one of which could have its tag cleared from the dialog. Everything around the button already treated those spools as tagged: the Tag ID column renders whichever identifier is present, and the payload the button sends clears both fields. Only the one condition disagreed. Either identifier now enables it, and clearing still removes both. The documented workaround — editing `extra.tag` in Spoolman's own interface — is no longer needed.
- **Assigning a spool to a slot holding a non-Bambu filament configured nothing, and could delete the assignment afterwards (#3084, reported by @anthonyma94; #3100, reported by @Sawtaytoes)** — swap a Bambu spool for one the AMS cannot read and Assign Spool published no `ams_filament_setting` at all: the printer kept showing `?` on its own screen and in the slicer, and only Configure, which publishes unconditionally, put anything there. Three backend decisions were reading the tray's `state` field to answer "is there a spool in this slot", and `state` cannot carry that. It is firmware-variant — the A1 Mini BMCU and the P1S Standard AMS report 3 for a loaded slot and never emit 11, and an AMS-HT reports a *loaded* tray as 9 because it does not feed into a shared buffer (#2594) — and it is also partly Bambuddy's own writing: the empty-slot cleanup stamps `state = 9` on every slot whose `tray_exist_bits` bit is 0, and when the bit comes back it refreshes only the `exists` annotation beside it. Either way a slot the firmware said was full sat in the cache reading `exists=True, state=9` until something configured it, and the deferred configuration that is supposed to rescue a pre-assigned slot could not fire either, because its own "has a spool appeared" test was the same 9. That is the deadlock removed from the assign path in #1322, still in place one step further along. The same 9 is what deleted the assignments in #3100: the replay never fired, so the row kept the empty fingerprint it was stored with, and the first tray report that named a filament was read as a spool swap and unlinked. Every one of the three now reads `tray_exist_bits` first, which is the bitmask firmware actually answers this question with and the one the printer card has drawn its `?` from since #2527; `state` remains the fallback for the external spool and anything else whose payload carries no presence bit. A blank tray report from a slot the bit calls occupied no longer unlinks anything either — off a print as well as during one — because a filament the AMS cannot identify is not a filament that was removed.
@@ -128,23 +145,114 @@ All notable changes to Bambuddy will be documented in this file.
- **An archive left empty by a printer refusing FTPS blamed the slicer (#2780, reported by @AntonPalmqvist)** — when a printer's file service answers port 990 with something that is not TLS, Bambuddy pauses transfers to it and the print in flight is archived with its name and timing only. That cause has been recorded on the archive since #2957, but the Archives banner was never taught about it: it ranked the three causes it knew, found none of them, and fell back to the original wording — the slicer did not leave the file on the card, go and switch on "Store sent files on external storage", here is a link to installation step 4. Every part of that is wrong for a refused handshake. The slicer did write the file, the reporter could see it on the stick from his own computer, the setting was already on, and there is nothing on his side to change. Worse, the banner dismisses one-shot into browser storage, so being shown the wrong explanation once meant never being shown the right one. It now has its own wording, in all fourteen languages, saying that the printer refused the connection, that this is not a slicer setting and not something the operator did, that Bambuddy comes back for the file after the five-minute pause so a brief episode fills itself in, and that a card still empty means the refusal outlasted the retry. It links to the troubleshooting entry for the handshake failure rather than to the installation guide. This cause now outranks the other three rather than falling to the bottom: the other three describe an install working as configured and each ends in something the operator can change, while this one reports a fault we cannot yet explain — and given the one-shot dismissal, ranking it below anything else would have hidden it permanently. It cannot mask a permanent cause in return, because the retry clears the marker when it succeeds, so a card still carrying this one is a printer whose file service is still refusing. The wiki page these reports are pointed at has also been corrected: it told people to power-cycle the printer, which the reporter who prompted that advice did on both of his printers to no effect, and which the code stopped saying for that reason. It now states what has been measured, says plainly that the trigger is unknown, and names the one log line worth collecting.
- **Custom filament profiles arrived in the slicer as Generic, or as the Bambu profile they were built on (#3003, reported by @marivo)** — the slot's filament id is the one field a custom profile travels in, and it holds eight characters on the printer. Bambuddy was putting an eighteen-character preset identifier in it whenever it could not find a real filament id. The printer stored the first eight and reported success, so the slot pointed at something that resolves nowhere: the slicer showed Generic and the printer's calibration table, keyed by the same field, no longer had a slot to key. Three printers in the support archive show it happening — an A1, a P1S and an H2D — so it was never specific to one model. Bambuddy now sends the slot's existing filament id, or the generic one for the material, both of which fit. Orca Cloud profiles were additionally never looked up at all, so theirs went in as a thirty-six-character identifier and fared worse still; they are now resolved like every other source. Also fixed on the way through: a failed Orca Cloud sign-in left its HTTP connection behind instead of closing it, which went unnoticed while only the settings page could trigger it and would have repeated on every spool assignment once the lookup above started using the same code. A profile that carries no filament id of its own — one that only stores its changes against the profile it inherits from — still cannot be told apart from that parent.
- **Hovering a muted control in the light theme made its label vanish (#1909, reported by @AntonPalmqvist)** — the tab strip on the Print Queue page was where it got noticed: point at **Batches** and the word turned white on a near-white background. It was never about that tab. The light theme keeps `text-white` readable by remapping it to the theme's foreground colour, but that remapping only ever matched the plain utility, not the `hover:` variant Tailwind compiles to a different selector — so roughly 400 controls across the app that dim their label at rest and brighten it on hover were brightening it to literal white, whatever the theme. They now follow the theme like everything else. Dark themes are unaffected, their foreground colour already being white, and the handful of buttons that turn a solid accent colour on hover read better for it rather than worse.
- **Every camera stopped working on 1.2.5.4 (#3001, reported by @Jieper001, confirmed by @JmanB52D and @hikingthunder)** — live view, snapshots, timelapse frames and the camera diagnostic all failed at once on every RTSP model — X1, H2 and P2 — with the in-app diagnostic reporting `capture_exception` at 0 ms while network reachability passed at 1 ms. That 0 ms is the whole story: the failure happened before a socket was opened. The RTSPS proxy added in 1.2.5.4 finished by hanging its set of in-flight connection handlers on the server object as an attribute. `asyncio.start_server` returns an `asyncio.base_events.Server`, which has a `__dict__` and accepts that; under uvloop it returns a `uvloop.loop.Server`, a Cython cdef class with no `__dict__`, which raises `AttributeError` outright. Every launch path this repo ships — the Dockerfile, `install/install.sh`, `deploy/bambuddy.service`, the Windows service and the SpoolBuddy installer — pins `--loop asyncio`, added for #1896, so none of them selects uvloop and none of them could hit this. What broke is the installs running a unit file we did not write. The Proxmox VE Helper-Scripts LXC composes its own `ExecStart` with no loop pinned, and `requirements.txt` pins `uvicorn[standard]`, which installs uvloop on Linux, so uvicorn's default `--loop auto` selects it — that is the reporter's install and the two that confirmed it. Native installs created before the #1896 pin landed on 2026-07-05 are in the same position for a different reason: `install/update.sh` never rewrites the unit file, so a service written before that date has never been given the flag by any update since. Those installs are also still exposed to #1896 itself, where a truncated Virtual Printer FTP upload corrupts a `.gcode.3mf` silently; the camera outage is simply the visible half. Hence a fix in the code rather than another flag in a unit file: the proxy now works on either loop instead of depending on the launch command to steer around it. The handler set now lives in a module-level registry keyed weakly by server, which both loops accept; keying it weakly rather than by `id(server)` means a proxy abandoned without a close takes its entry with it, instead of leaking one forever and eventually handing a new server a dead one's handlers once CPython recycles the address. A1 and P1 use the chamber-image protocol and return before the proxy is built, so they were never affected, and external RTSPS cameras caught the error and fell back to a direct connection, so they kept working without the TLS workaround. The reason the test suite could not see any of this is that `conftest` builds its event loop from the default policy, so every async test in the repo runs on the selector loop — the one loop where the assignment was legal. The regression is now pinned twice: once by a test that drives the real function on a real uvloop loop, and once by a test that gives it a `__slots__` server, so the contract holds even where uvloop is not installed. The two external-camera teardowns were also switched to `close_tls_proxy`, which #2968 introduced and left them out of, so they no longer leave handlers running past the server that owned them.
- **Some archived 3MFs lost their G-code when re-imported into the File Manager (#2993, reported via the in-app form)** — they never lost it. The download serves the stored file byte for byte, and the G-code was still in the zip; what differed was who was asked. On the archive side the answer came from the file itself — the green GCODE badge reads the layer count and print time that were parsed out of the plate G-code — while the library decided from the filename alone, so a sliced 3MF stored as `Foo.3mf` rather than `Foo.gcode.3mf` carried the badge and still came back as a source-only project with no Print button. That splits on how the print reached the printer, not on anything about the file — a slicer's LAN send names it `.gcode.3mf`, while a per-plate export or a cloud-dispatched print arrives as plain `.3mf` — which is why it looked random. Both sides now ask the same question of the zip itself, and every route into the library (upload, ZIP import, MakerWorld, external-folder scan) classifies on content rather than on the name. Files already in your library are re-checked once on the next start. The backend was always willing to print these, so this was only ever the interface refusing to offer something that would have worked; a genuine model file is unaffected, and one that now shows **Print** correctly stops offering **Slice**.
- **Swapping a spool left the previous spool's preset name on the AMS slot card** — pull a Bambu ABS Orange out of A1, put a PLA Matte Dark Blue in, and the card still read "Bambu ABS" against the new colour. The backend had it right all along: the RFID auto-assign rewrites the slot's stored preset the moment the tag is read. The browser simply never refetched it. The slot card reads that stored preset ahead of the filament id the printer is reporting, so one cached row outranked correct data arriving over the WebSocket — and because every other field on the card (colour, material, fill, K value) rides the status push and updated instantly, it surfaced as a single wrong line rather than an obviously stale card. The manual assign path already refreshed it; the RFID path did not. Spoolman mode was the worse half of the same bug: its AMS sync writes that same row but announced nothing at all, so there was no event to refresh on — it now reports each slot it changed or cleared. Two further changes make the card right without waiting on any of that: the slot's queries no longer sit behind the 3-second cascade debounce meant for print completion (a swap touches one slot, and any further event restarted that timer), and the card now ignores a stored preset whose filament id disagrees with what the printer reports in the slot, so the correct name is on screen from the status push alone. A hand-picked preset name still wins wherever the stored row and the slot agree, and a user or local preset — whose ids genuinely cannot be compared — is untouched.
- **A print that could not fetch its own 3MF could be charged another plate's filament (#2957, reported by @doncaruana)** — when the source file is missing, the usage tracker looks for a replacement in the library or in a previous archive and matched on the filename stem alone. That is far weaker evidence than it looks: Bambu Studio writes the printer-side filename from the project's `Title` metadata, so every plate of a project arrives on the printer under one name however the file was renamed on disk. The reporter's single-filament job was handed a previous archive's three-filament plate and three spools were debited for material they never extruded, with nothing on the archive to say the numbers were someone else's. A candidate is now refused when it holds a different plate than the one running, and an all-plates export is refused unless it actually contains that plate — previously the plate was looked for later, found missing, and every filament in the file was summed onto one plate's print. Where the printer echoes only the 3MF filename and the plate cannot be known at all, which is the reporter's own firmware, the candidate is still accepted on its name and a warning now says so rather than the deduction happening silently.
- **A slow-but-healthy 3MF download was cut off at 30 seconds (#2957, reported by @doncaruana)** — `ftp_timeout` is handed to every download as *both* the socket inactivity timeout and the whole-transfer deadline, which makes its default a cap on how big a file a printer is allowed to serve. The reporter measured the same 5.4 MB 3MF at 45 s off a worn P1S SD card and 25 s off a new one, and a 15.15 MB 3MF at 105 s; an older 7.8 MB archive in his logs survived only because the transfer happened to finish inside the retry grace. None of those links were broken — they were slow, which is what the inactivity timeout exists to tell apart. The total deadline now follows the size the printer reports for the file, against the same pessimistic 25 KB/s floor the upload path has used since #2529. The extension is granted only once the printer has answered `SIZE`, so a printer that is not answering at all still fails on schedule and the executor queue wait #2572 bounded is unaffected, and it is capped at five minutes because the print-start handler holds a pooled database connection for the length of its 3MF hunt. A transfer that overruns even that stretched deadline is not retried, for the reason an overrunning upload has not been since #2529: the retry would spend another full deadline reaching the same conclusion.
- **Bambuddy could run two heavy FTPS transfers against one printer at once (#2957, reported by @doncaruana)** — the reporter watched Bambu Studio itself lose its connection to a P1S while Bambuddy pulled a 12 MB 3MF, and a later log caught two Bambuddy downloads of the *same* 5,250,969-byte file overlapping during print start. A P1S at that moment is already serving the print off the same SD card and answering MQTT. Downloads now take turns per printer, the way uploads have since #2529. The gate is deliberately soft — a download that cannot have it within 30 seconds proceeds anyway, because a print losing its 3MF to queueing would be worse than the contention, and the printer file browser stays outside it entirely so a 3MF preview never waits out somebody else's multi-gigabyte selection — and it is now meaningful at all: the 90-second cap on a multi-path lookup used to return while its worker thread kept walking the remaining paths, still holding the printer's socket, so the walk is cancelled and waited out before the printer is handed on.
- **The cover thumbnail re-downloaded a 3MF another part of Bambuddy had just fetched (#2957, reported by @doncaruana)** — the cover endpoint and the print-start archive flow share a 3MF cache so whichever fetches first hands the file to the other (#972), but the cover endpoint consulted it once on the way in and then retried for up to two and a half minutes without ever looking again. In the reporter's log the archive flow published the file 42 seconds into that sequence and the cover's third attempt still pulled its own full copy of it, off a printer that was mid-print. It now looks again before each retry, and a file it picked up that way is left alone rather than re-registered under its own name or deleted on the way out — it belongs to the archive flow.
- **A camera snapshot occasionally logged an asyncio ERROR with a traceback into the camera code (#2968, reported by @ceasley)** — `Task was destroyed but it is pending!`, naming `create_tls_proxy.<locals>._handle()`, once every few hundred snapshots. Nothing was actually broken by it — the snapshots on either side of each one succeeded — but it is the loudest thing in an otherwise clean log and it reads like a camera fault. `asyncio.start_server` wraps the connection callback in a task and keeps only a weak reference to it, so the RTSPS proxy's handler could be collected while still waiting on its two forwarders; that message is the garbage collector noticing. Teardown had the matching gap: `server.close()` stops the listener but leaves established connections running, so the close waited on a handler that only finishes when the *peer* drops the socket — and by then ffmpeg has already been reaped, so the connection is dead weight nobody is going to close. The proxy now holds its handlers for as long as they run and cancels them at shutdown, which is what `Server.close_clients()` would do natively if it had not landed in Python 3.13 (Bambuddy supports 3.10). Both the snapshot path and the streaming endpoint go through the same shutdown. 7 regression tests, against a real TLS listener rather than a mock, since the bug is in socket teardown.
- **Deleting a print with no 3MF left its timelapse and its uploaded source on disk (#2968, reported by @ceasley)** — and logged the ordinary case as a security incident. An archive created without a 3MF carries an empty `file_path`, and both delete paths derived the directory to remove from that path: finding nothing, they removed nothing and wrote `SECURITY: Refusing to delete files for archive 7 - file_path is empty or invalid: ''` at ERROR. That was true when such an archive really was an empty row, and stopped being true once one could own files — `<archive_dir>/<id>/` holds its timelapse and finish photos, and `archive/no_source/<id>/` holds a source 3MF uploaded onto it afterwards. Neither was ever removed, so the row went and the video stayed. That is not a corner case on an H2-series or P2S printer, where a print sent from Bambu Studio *always* archives without a 3MF — it is most of the library, and the space it used never came back. Both are now cleaned up, but by different means, because `<archive_dir>/<id>` **shares a namespace with the per-printer folders**: a normal archive lives at `<archive_dir>/<printer_id>/<timestamp>_<name>/`, so `archive/1` is printer 1's folder *and* the directory the shared path helper hands archive id 1 — and archive ids and printer ids are small integers from unrelated sequences, so on every install the first few archives collide with the printers. A recursive delete there would take every print that printer ever made. So `archive/no_source/<id>/`, which is nested a level deeper under a name no printer id can take, is removed whole, while the id-named directory gives up only the files the row itself names — its `photos` subdirectory and its recorded video — and is then removed only if that left it empty, which a printer folder holding prints never is. Anything unrecognised keeps it alive and is left behind rather than guessed at. **The same collision is now refused for corrupted rows**: nothing one level under the archive directory is deleted at all, and since an archive directory has been two levels deep since the first commit, a `file_path` that has lost a path component can no longer point the delete at a printer folder. The shared `<base_dir>/photos` directory that every no-3MF archive once wrote into is never a candidate either, because removing it on one delete would take the others' photos. **Hard delete had its own copy of these rules** — the helper it was extracted from says in its docstring that it exists so the two cannot drift apart, and they had — so it now shares them, which also means it stops skipping the print-log thumbnail cleanup when a guard trips. The `SECURITY:` wording is kept for the two conditions that really are one, and both still remove the row so an archive cannot get stuck in the UI. 20 regression tests.
- **Every ffmpeg failure logged its build banner instead of the error (#2968, reported by @ceasley)** — ffmpeg opens each run with about twenty lines of version, build and library banner and prints its diagnosis *last*. Seven of the eleven places Bambuddy logs ffmpeg or ffprobe output kept the first 200 to 500 characters of that, so the banner survived and the error did not. The reporter's log carried twelve camera-capture errors and every one of them read `ffmpeg frame bytes capture failed (code 183): ffmpeg version 7.1.4-0+deb13u1 … built with gcc 14 … configuration: --prefix=/usr --extra-version=` — identical on every install, and silent about why the capture failed. The exit code was the only usable information in the line. The banner-stripping summariser written for the camera streaming endpoint (#925) now lives in a shared module and every ffmpeg and ffprobe stderr goes through it: snapshot capture, last-frame extraction, the layer-timelapse stitch, the archive's MP4 conversion, external USB and RTSP capture and streaming, and timelapse post-processing. It drops the banner, keeps the last ten meaningful lines — the error plus the input analysis that explains it — and leaves indented `Duration:` and `Stream #0:0` lines alone, since those are diagnosis rather than boilerplate. **Two things the scattered copies also got wrong.** ffmpeg echoes its input URL back in its error output, and seven of those call sites logged it without masking, publishing a printer access code or an external camera's password into a log file people routinely attach to public issues; masking is now part of the summary, applied to the whole string before anything is dropped, so a credential straddling the cut cannot leave its tail behind. And ten sites called a bare `.decode()` on bytes ffmpeg had copied stream fragments into, which could raise `UnicodeDecodeError` while reporting an unrelated failure — the summariser takes bytes and decodes them safely. A failure whose output was banner only now says `no diagnostic output` rather than trailing off after the colon, and the whole line is capped, since ffmpeg quotes back what the peer sent it. 20 regression tests, including one that fails if a twelfth call site starts truncating or decoding by hand.
- **Every print archived from a Bambu slice had no bed temperature, so preheat guessed one (#2989, reported by @senguendk)** — `PrintArchive.bed_temperature` was parsed by looking for a `bed_temperature` key in the project settings. BambuStudio does not write one: it stores a separate per-filament array for each plate type — `cool_plate_temp`, `eng_plate_temp`, `hot_plate_temp`, `textured_plate_temp`, `supertack_plate_temp` — and names the plate the project is sliced for in `curr_bed_type`. `bed_temperature` is the Orca/PrusaSlicer spelling, so the lookup matched nothing at all: **0 of 455 real 3MFs** on a live install produced a bed temperature, and every archive stored NULL. That is why preheat logged "archive has no bed_temperature metadata" on jobs sliced perfectly normally, and then heated the bed to the configured keep-warm temperature — 90°C by default — for prints that asked for 55°C. The extractor now reads the array the fitted plate points at, taking the first-layer value in preference since that is what the printer heats to before the print starts; the same 455 files now all resolve, across the four plate types they were sliced for. The plate names and the plate-to-key mapping are BambuStudio's own, so a plate a future release adds reads as unknown rather than silently taking another plate's temperature — as does `Default Plate`, which BambuStudio maps to no key. The per-filament array resolves to its highest entry rather than its first: the bed has one temperature and a 0 in that array means "this filament cannot print on this plate", so the first entry would record a cold bed for any project whose first filament is not one the fitted plate is heated for. An array that is all zeros is left unrecorded rather than stored as 0. Orca-exported 3MFs keep parsing through the generic spelling, which is now the fallback rather than the only thing tried. **Archives you already have are repaired too**: a one-shot pass at startup re-reads the 3MF still on disk for every archive whose bed temperature is blank, so preheat gets the right figure when you reprint an old job from the queue rather than only on new ones. It fills blanks and nothing else — a temperature already recorded is never overwritten, an archive whose file is gone stays blank, and a corrupted 3MF is skipped rather than failing the boot. The plate mapping is now shared between the ingest path and the repair so the two cannot read a 3MF differently. A dead copy of the old, wrong lookup that nothing called was removed at the same time, so it cannot be wired back up by accident. 36 regression tests.
- **A manual K-profile calibration left a print in your archive** — Bambuddy already recognises the printer's automatic pressure-advance run, `auto_pa_line_calib_mode`, and skips archiving and notifying for it. Started by hand instead of automatically before a print, the same calibration reports under its own name with no `auto_` prefix — and manual flow dynamics has *two* shapes, a line and a pattern, so neither `pa_line_calib_mode` nor `pa_pattern_calib_mode` matched anything. Both arrive exactly the way the automatic one does — a bare subtask name with no `/usr/` path — which meant the archive path swept FTP for a 3MF that cannot exist and then wrote a no-3MF archive named after the calibration, on a printer that was in the middle of calibrating. Both are now on the same list, which is the one place both the print-start and print-complete callbacks consult. Matching stays exact after normalising path, suffix and case, so a file you deliberately named `pa_pattern_calib_mode_v2.3mf` — or your own `pa_bracket.3mf` — is still archived as the print it is. Wiki updated.
- **A spool you assigned to an AMS slot unassigned itself seconds later (#2987, reported by @frethop)** — and the slot's colour changed at the same time. It looked like Bambu Studio and Bambuddy fighting over the slot; the reporter's log shows Bambuddy losing to itself. P1S firmware 01.10.00.00 reads every **lowercase** hex letter in an AMS `tray_color` as a zero, and hides it completely: the command response echoes back the value you sent and reports `result: "success"`, so only the next AMS push reveals what was really stored. The spool-assign path sent `spool.rgba` verbatim, and that column stores lowercase — so `09ff00ff` became `09000000` on the printer and `ff5100ff` became `00510000`, while the one uppercase write in the same window round-tripped intact. That is the visible colour change. It is also what deleted the assignment: the auto-unlink sweep asks whether the slot still matches the spool it is assigned to, the mangled colour no longer did, and the assignment Bambuddy had created four seconds earlier was removed. Re-assigning could not help, because the **Configure Slot** dialog seeds its colour from whatever the printer currently reports — so it wrote the mangled colour straight back and cemented it, which is the loop in the report's steps 4 and 5. Colours are now uppercased at the single point the MQTT command is assembled rather than in each of the four routes that configure a slot, because a caller that forgets is exactly how this arrived. Nothing else about the command changes: no padding, no invented alpha, and `tray_type` / `tray_sub_brands` keep their case, where it carries meaning. **Two more things found in the same log.** A spool with a brand but no subtype was configured with the literal string `None` in its name — `"Sunlu PLA Matte None"` went on the wire, because the branded branch interpolated the subtype without checking it while the unbranded branch guarded it. And the FTP log is now readable: a `426` whose bytes Bambuddy has verified against the printer is how Bambu's FTPS normally ends a transfer, not a fault, so it is logged at INFO instead of WARNING. It fired 54 times in this one bundle, every single one followed by a completed upload, and it was burying the 26 TLS handshake failures in the same log that actually cost the reporter two prints. A `426` whose bytes do **not** verify is still an error and still fails the upload. 24 regression tests.
- **One ASA spool parked in the AMS added 20 minutes to every PLA print (#2886, reported by @FirstRulez)** — Preheat's chamber target was the maximum across *every loaded AMS tray*, with no reference to the job. The reporter's P2S holds PETG Pro, PLA, ASA and PETG; the ASA row of the filament map says 45°C, so a PLA-only plate was dispatched with `chamber_target=45°C`, the bed driven to 90°C to reach it, and the full 900s max-wait plus 300s soak burned before the upload even started — every time, because a P2S has no chamber heater and the chamber tops out around 33°C, so the wait can only ever end on the timeout. Their log carries fifteen of these. The intent was never in doubt: the resolution order documented one screen above reads "PLA-only print derives 0 → chamber phase auto-skips", but it was implemented as PLA-only **AMS** rather than PLA-only **print**, and only misfires on a mixed-material load. The derivation now reads the trays the item's `ams_mapping` actually names — the same array the print command puts on the wire, `[-1, -1, -1, 1]` in their case, addressing exactly the PLA slot — so the ASA two slots over contributes nothing and the stage skips outright. Multi-material prints are unaffected: the maximum is still taken, just across the trays the plate loads, so an ASA the print really does use is still the binding constraint. An item whose mapping is missing or still unresolved keeps the whole-unit scan, since that is the only signal left and narrowing to nothing would disable preheat for prints that need it. The bed hold between jobs (`queue_keep_bed_warm`) is gated on the same derivation and was holding beds at 90°C for the same wrong reason; it now reads the next item's mapping too. **The external spool is no longer invisible to this**: the scan only ever looked at `raw_data['ams']`, so an ASA print fed from the external feed derived 0 and got no preheat at all — a mapping that names 254/255 is now honoured, while an item with no mapping still derives from the AMS alone so nothing starts preheating that did not before. That covers the mappings Bambuddy builds itself, from the print dialog or the dispatcher's own matcher. It does not cover a mapping captured from a slicer through a Virtual Printer: BambuStudio writes the external spool as `-1` there, which is the same value it writes for a slot the plate does not use, so the two cannot be told apart. A wholly external one carries no usable mapping and falls back to the whole-unit scan as before; a mixed one derives from its AMS trays and the external half stays unread — which is exactly what it did before this change, since nothing ever read `vt_tray`. 29 regression tests, built from the trays and mapping in the reporter's own support bundle.
- **Spoolman reset your renamed extra fields on every restart (#2983, reported by @ngreatorex)** — Bambuddy checked whether one of its four custom spool fields existed by calling `GET /field/spool/{name}`. Spoolman has never served that: its API declares only `POST` and `DELETE` at that path, so the check answered **405 Method Not Allowed** every single time and could never succeed. Each call then fell through to `POST /field/spool/{name}` — and that endpoint is an *upsert*, not a create. It answers 200 whether or not the field is already there, so a field you had renamed, retyped or given a default to in Spoolman's own UI was silently reset to Bambuddy's version of it, and an untrue `Created Spoolman extra field` was logged alongside. The reporter's log carried 60 of those lines in three days. Existence now comes from the documented `GET /field/spool` listing, matched on the field's `key` rather than its display `name` — a rename is the same field, and treating it as a missing one is what caused the overwrite. An existing field is left completely alone. **You can now rename these fields in Spoolman and the name will stick.** Registering all four also costs one request instead of four, and none at all on a client that has already looked once. If the listing can't be read at all, Bambuddy falls back to attempting the write exactly as before, so an unexpected Spoolman build is no worse off than today.
- **The internal slicer picked PETG for a PLA plate, and an A1 process for a P1S (#2982, reported by @Igiegel)** — Both traced to one line of the slicer sidecar: its bundled-profile listing read `filament_type` off the leaf preset and never reported `compatible_printers` at all. Measured against the shipped trees, the leaf read finds nothing — zero of the 1156 filament presets in OrcaSlicer 2.4.2 carry a material, and zero of the 1792 in BambuStudio 02.08.02.61 — because it lives one to four hops up the `inherits` chain (`Bambu ABS @BBL A1` → `Bambu ABS @base` → `fdm_filament_abs`). With no material on any of them the pre-pick had only colour and source to go on, so a white PLA plate drew `Bambu PETG Basic` on an A1 mini and `Bambu PC` on a P1S. The sidecar now walks the chain, which recovers the material for 1124 of the 1156 (the other 32 name a parent Bambu's own bundle does not contain, and stay listed with no material rather than being dropped). **`compatible_printers` is the second half**, and it is the only truthful account of which printer a preset belongs to: the bundle ships no process preset named after a P1S, an X1, an X1E or an H2D Pro — all ten of the P1S's are named `@BBL X1C` and name the P1S only in that list. Reading the printer out of the preset *name* therefore made a P1S look like it had no compatible process at all: all 198 were hidden behind “Show all” and the auto-pick fell through to an alphabetically-first `0.06mm Fine @BBL A1 0.2 nozzle` the CLI then refused with “the selected printer is not compatible with the process preset”. A P1S now gets the `0.20mm Standard @BBL X1C` it should always have had, and 73 filaments instead of 4. **This half needs the updated sidecar image** — an older one simply reports nothing and Bambuddy stays on the name matcher, degraded exactly as before rather than broken. Three hardenings ride along so a stale sidecar fails more gracefully: a filament preset that states a *different* material than the plate asks for is now skipped outright rather than merely scored down (a preset stating no material stays eligible — unknown is not wrong), a slot is no longer held on printer-compatibility alone once its material turns out to disagree (a preset you chose yourself is exempt — printing PETG on a plate labelled PLA is a legitimate thing to do), and a dropdown the printer filter would empty now shows everything instead, since a visible preset for the wrong printer can be changed and an empty list cannot. `filament_colour` stays null throughout, which is correct: no BBL profile carries a colour at any depth, because colour is a spool attribute rather than a profile one.
- **Every slice that didn't name its own process quietly got the slowest one the slicer ships** — Found while tracing #2982. Within a tier the preset list is alphabetical, and Bambu's naming puts the finest layer height first, so the auto-pick landed on `0.08mm Extra Fine` for an X1 Carbon and `0.06mm Fine` for an A1 mini. Correct presets, but nobody's idea of a default. Among candidates that are equally valid for the selected printer, the one nearest 0.2mm now wins — `0.20mm Standard` where it exists, the closest thing to it otherwise, ties breaking toward the coarser and therefore faster height. A preset whose name carries no readable height is still pickable when it is the only candidate, and a process the 3MF named still overrides all of this.
- **An H2D Pro classified every bundled preset as another printer's** — Also found while tracing #2982, and the same shape as the A1 mini's `A1M` rename (#1649): the bundle names H2D Pro presets `@BBL H2DP` while the printer preset, and the model registry with it, spells the model `H2D Pro`. The alias table now carries the pair. It stays deliberately narrow — `H2DP` and a plain `H2D` are still different machines and must not collapse.
- **A printer card said "Unknown stage (72)" where it now says "Preparing"** — New models report stage numbers before Bambuddy learns their names, and the H2C still has several. Until now those reached the card verbatim, as a number that means nothing to the person reading it, on a line that otherwise names what the printer is doing. Every stage that has turned out to be unnamed so far has been part of the run-up to printing, so an unnamed one now reads as "Preparing" — the same label stage 74 already carries, rather than a second spelling of the same idea. The substitution is display-only and deliberately not pushed down into `get_stage_name`: that function also feeds the stage-transition log line and the once-per-session warning that exists precisely to capture unnamed stages so they can be named in a later release, and there the number is the entire diagnostic value. Both paths are pinned by tests that assert they disagree for an unnamed stage and agree for a named one, so a future tidy-up cannot quietly collapse them and blind the thing that reports these. The idle sentinels (255 on A1/P1, -1 on X1) still resolve to no stage at all rather than being swept up as unnamed.
- **An AMS slot card showed a multi-colour spool as one flat band (#2967, reported by @NeighborGeek)** — A Ziro "Colorful Mist" — yellow, cyan and pink, effect Tri Color — hovered on the printer card as a single pink rectangle, because a printer reports exactly one `tray_color` hex per tray and nothing else. Telemetry cannot describe a gradient or a surface effect and never will, so the header now paints the *spool's* own swatch whenever the bound spool carries extra colour stops or an effect, through the same builder the Inventory swatches use — the two surfaces cannot drift because there is one implementation. A plain single-colour spool keeps the flat colour it has always had, so the common case goes nowhere near the gradient path. Any stop at all counts, not just two: the colour layer ignores the base hex the moment stops exist, so a one-stop spool renders that stop rather than the slot's hex, and honouring it is what keeps the card agreeing with Inventory. The colour name and the print dialog's slot dropdown were the other two halves of the report and were already fixed on dev by #2875 and the slot-naming change that landed the day after it was filed. **Spoolman mode gained the gradient in the process**: Spoolman holds the extra stops in `multi_color_hexes` and the label renderer had been reading them for releases, but `_map_spoolman_spool` never returned them, so the identical roll registered in Spoolman rendered flat while the internally-managed one did not. Both now share one parser rather than reading the same field two ways. The asymmetry that remains is Spoolman's own and is pinned by a test rather than left to be rediscovered: it has no field for a surface effect — its only neighbouring field, `multi_color_direction`, describes how the stops are laid out, not that the roll is silk — so `effect_type` is None there instead of guessed at. The colour name moves onto the same scrim the vendor badge already uses once the background has more than one band, because a single hex cannot decide legibility across yellow, cyan and pink and the label sits dead centre where the background is likeliest to change under it; a single stop or an effect over one colour leaves a real base colour to test, and keeps the contrast rule it had. Wiki updated. 19 backend and 8 frontend regression tests.
- **Everything the internal slicer produced was Bambu green, whatever filament was picked (#2977, reported by @fadudba)** — A slice through Bambuddy's own slicer came out with `filament_colour = #00AE42` every time: a green plate thumbnail regardless of the profile chosen, and a **Color mismatch** in the Print dialog against the AMS slot the job had just been correctly mapped to. The reason is that a colour is not a property of a filament *preset* in either slicer — it belongs to the project, and their GUIs set it from the plate — so nothing was attached to the preset Bambuddy sent by name and the CLI fell back to its own compiled-in default, which is Bambu green. Verified against a 02.08.02.61 sidecar with the reporter's exact triplet: as sent, `['#00AE42']`; with a colour written onto the same profile, the colour asked for. Each filament row in the slice dialog now carries an editable **colour swatch**, and the colour reaches both `project_settings.config` and `slice_info.config`, which is what the thumbnail and the AMS mapping actually read. The swatch is pre-filled from the colour that slot was designed with — read from the source 3MF's own project settings — then the preset's `default_filament_colour`, then the slicer's green. It is offered on single-filament sources too, because an **STL, and equally a mesh-only 3MF exported from CAD, has no colour anywhere else to inherit**; measured, a colourless source records `color="#00AE42"` in its own slice info, so a fallback that read the *last sliced* colour rather than the *designed* one would have been circular. `default_filament_colour` is deliberately not treated as the answer on its own: the CLI never reads it — a profile carrying only that still slices green — it is consumed by the GUI when a project is created, so it is read and rewritten as `filament_colour`, which the CLI does honour. Bambu's bundled filament profiles define it nowhere at all (zero occurrences across the whole shipped tree), which is why it can only be one link in the chain. A slot the user did not touch and that has no designed colour submits an empty string rather than the swatch's displayed default, because a sent colour outranks the preset's own and pinning the placeholder would silently discard the real colour of an imported OrcaSlicer profile that carries one. Slicer Pipelines pick up the same chain without carrying a swatch of their own. The control sits beside the filament dropdown, styled like it and the same height, showing the swatch and its hex together. That placement is the third attempt and the first that reads as a control: a bare swatch in the label row looked exactly like the read-only dot multi-colour rows had carried for releases, and adding the hex beside it only made it look like a caption on the label &mdash; so on a single-filament STL, the one source with no colour to inherit and therefore the case the control exists for, nothing suggested anything was settable. The swatch and the hex are wrapped in one label bound to the input, so a click anywhere on it opens the picker rather than only a 16px dot being live. The colour is also painted on the input directly rather than relying only on the browser's native colour-swatch pseudo-element, since an unlit swatch is indistinguishable from no swatch at all. Translated in all 14 locales, wiki updated, and covered by 38 backend and 14 frontend regression tests.
- **A filament preset the slicer could not resolve was sliced as PLA at 200 °C without saying so** — Found while investigating #2977. Bambuddy names the preset to inherit and the sidecar resolves it against its bundled profile tree; when that tree does not contain the name, nothing rejects it. The CLI inherits nothing, falls back to its compiled-in defaults for every field, and returns a well-formed success — so a PETG preset whose name a sidecar image predates prints at PLA temperatures with no diagnostic anywhere. Measured against a 02.08.02.61 sidecar: an unresolvable name slices as `filament_type ["PLA"]` at `nozzle_temperature ["200"]` with `filament_ids [""]` and `filament_vendor ["(Undefined)"]`. Bambuddy now recognises that pair and logs a warning naming the slot and the preset that was picked, pointing at the sidecar image as the fix. The file is **kept rather than refused**, unlike the missing start G-code of #2838: this one prints, it is only wrong, and someone may well be slicing deliberately with a profile their sidecar predates — so the choice is theirs to make with the temperatures in front of them. Both signals are required together, which is what keeps it from firing on the two legitimate cases that look similar: a hand-written profile that simply never named a vendor still carries a real filament id, and a user's own cloud preset carries a vendor while legitimately having no bundled id.
- **A failure reason the backend derived and the same reason a user picked counted as two different reasons (#2974, reported by @ojimpo)** — `failure_reason` was written in three vocabularies and nothing reconciled them in storage. The backend wrote English display labels (`"Layer shift"`), older builds of the archive editor wrote the *translated* label in whatever locale that user was running, and two stale-archive paths wrote English prose sentences (`"Stale - reconciled after reconnect, end time unknown"`). All three land in one column — the PATCH route has mirrored the field onto the latest print-log entry since #1444 — and the Failure Analysis widget groups on the raw value, so one real cause occupied several buckets. Measured on a live install before this landed: 91 rows reading `"User cancelled"` beside 1 reading `"userCancelled"`. In an English UI those render as the same words twice with different counts, which is why it went unnoticed; in any other locale one of the two stays English, because a stored label has no key for `t()` to resolve. The editor was worse than cosmetic about it: its reverse lookup compared the stored value against `t(...)` in the *current* locale, so for a non-English user nothing matched, the dropdown opened empty over an archive that plainly showed a reason, and saving from that state wrote the empty selection over the stored text. **One vocabulary now.** The keys were already canonical and already enforced — `_FAILURE_REASON_KEYS` in `api/routes/print_log.py` rejects anything else with a 400, and says why in its own comment — so this is `derive_failure_reason` being brought in line with a rule the rest of the stack had been keeping. `_HMS_FAILURE_REASONS` stores keys, the cancel branch returns `userCancelled`, and both stale paths write a new `noStatusUpdate` key rather than prose; which of the two stale situations occurred is already carried by `status`, so collapsing them loses nothing and gives Statistics one bucket instead of two untranslatable sentences. **Existing rows are converted**, which was the open question on the issue: a startup migration folds 168 historical labels onto the 12 keys across both columns. It is exact rather than a guess — every label across all 14 locales resolves to exactly one key, verified with no collisions — and the map is a frozen snapshot rather than something read from the locale files at run time, because it maps what was written historically and regenerating it would silently stop recognising the very rows it exists to convert. A value outside the map is deliberately left alone; guessing at it would be worse than leaving one honest string in its own bucket. The migration carries no one-shot settings flag on purpose: it only matches values in the map and a key is never a label, so it is self-terminating, and a flag would permanently skip anyone restoring an older database. **The editor no longer destroys what it cannot read** — an unrecognised value keeps its own option in the dropdown and survives a save, instead of initialising to empty and overwriting the stored text. Covered by 13 migration cases across both tables, including the live 91-vs-1 split, both stale sentences, free text left untouched, and running twice changing nothing; plus a test asserting every value the backend can derive is a key the rest of the stack accepts, so a display label cannot creep back into the map the way it did before. New `noStatusUpdate` label translated in all 14 locales.
- **Every page was a blank white screen on iOS 16.0-16.3 (#2971, reported by @zevulos)** — An iPhone on iOS 16 loaded nothing at all: no error, no partial render, just white, over LAN IP and over an HTTPS domain alike, while the same install was fine on Android, macOS, Windows and Linux. The cause was one regular expression. `remark-gfm`, added in v1.2.5 for the folder README panel, reaches `mdast-util-gfm-autolink-literal`, whose module body contains a lookbehind assertion — `(?<=` — that Safari did not support until **16.4**. A regex literal is validated when its module is *compiled*, not when the function holding it runs, so this was never going to fail as a broken README panel: `FolderReadmePanel` -> `FileManagerPage` -> `App` is a plain static import chain, the regex landed in the entry chunk, and the browser refused to compile all 10 MB of it. Nothing executed, so nothing rendered. Every install from v1.2.5 onward has been unusable on those iOS versions, and v1.2.4 is the last release that loads on them. **The fix.** The panel now renders GFM through a locally composed plugin that registers four of `remark-gfm`'s five sub-extensions — tables, strikethrough, task lists and footnotes — and omits autolink literals, which is the only one that carries the lookbehind. Composing rather than configuring is forced by the nature of the bug: importing `remark-gfm` at all is what breaks the page, so no runtime option could have reached it. The visible cost is that a bare `https://example.com` or `foo@example.com` typed into a folder README no longer turns itself into a link; `[text](url)` and `<https://example.com>` are core markdown and still do. `remark-gfm`, `mdast-util-gfm` and `micromark-extension-gfm` leave the dependency tree, and the bundle is 23 KB smaller. **Why the build never said anything.** Vite's `build.target` governs syntax lowering, and esbuild does not rewrite regular expressions — measured here, a lookbehind builds silently under `safari15`, `safari16.0` and `es2020` alike, which is exactly how this shipped and then sat unnoticed for two months. So the guard is a real check rather than a compiler setting: `npm run build` now ends in `check-browser-baseline.mjs`, which scans the emitted bundles for syntax that Safari 16.0 cannot parse and fails the build with the offending snippet and the dependency-hunting command. It is deliberately scoped to *parse-time* failures only — a missing runtime API breaks one feature, while one of these takes down the whole app, and there is no graceful degradation to fall back on. Covered by seven renderer tests that pin both halves of the trade: each surviving GFM feature still renders, and the two forms of autolinking stay off on purpose so a future dependency bump cannot quietly bring the lookbehind back.
- **The print dialog named an AMS slot after the wrong spool** — The slot dropdown described every slot from the printer's own telemetry, and the printer cannot describe a spool it did not sell: a tray record has no brand field, `tray_sub_brands` is left empty for anything that is not a Bambu spool, and the colour is a bare hex the client resolves against Bambu's colour catalogue. A Devil Design PLA Basic Orange assigned in Bambuddy therefore read as "PLA (Sunflower Yellow)" — Bambu sell a Sunflower Yellow at the same `FEC600` — while the printer card, which reads the assignment, named it correctly. The two views now agree: `GET /printers/{id}/inventory-remain` carries each bound slot's brand, material, subtype, colour name and hex alongside the pooling key it already sent, and the dialog prefers that over telemetry, falling back field by field so a spool with no stored colour name still gets the catalogue lookup it had before, and re-reading it on every open so a spool assigned moments earlier is named correctly straight away. Resolved server-side, so internal inventory and Spoolman mode answer identically rather than the client re-deriving a rule that differs per mode. Matching is deliberately untouched and still runs on the printer's telemetry, so the auto-assignment and the colour-mismatch warning cannot start disagreeing with what the dispatcher does.
- **A K profile could be saved against the wrong hotend, and applied to the wrong one** — Which nozzle an AMS slot feeds, and how wide it is, was worked out independently in seven places, each reading the printer's first nozzle entry for every slot on the machine. That is correct on a single-nozzle printer and on a dual-nozzle printer with matching nozzles, and wrong the moment two sizes are fitted: the K profile for the other hotend was looked up, and with the per-model presets above the wrong preset would have been too. The resolution now lives in one place, and which array entry belongs to which hotend is no longer inferred — measured on an H2D fitted with a 0.4 on the left and a 0.6 on the right, the first entry reads the **right** hotend, so the array is indexed by extruder id. Separately, the spool form identified a chosen calibration by `cali_idx` alone, and the printer numbers its calibration table **per nozzle** — on a dual-nozzle printer the same index exists on both hotends meaning different things, so saving could persist the other hotend's K value and nozzle diameter. Each hotend is now keyed by printer, extruder and diameter throughout, which also lets a 0.4 and a 0.6 profile for the same hotend coexist — something both K tables could always store but the picker could not express. SpoolBuddy's write-tag page carried a verbatim copy of the same lookup and gets the same fix.
- **RFID auto-assign picked a K profile without checking which hotend it was calibrated on** — The first stored profile matching the printer and nozzle size won outright, with no extruder test at all. On a dual-nozzle printer a spool calibrated on both hotends therefore had a coin toss decide which pressure-advance value the slot got, on the path that runs unattended every time a Bambu spool is loaded.
- **Linking a Spoolman spool by tag configured the slot as generic filament** — That path resolved no slicer preset whatsoever and went straight to the generic material id, so a Spoolman spool with a preset set in inventory lost it the moment it was linked by tag. The same defect #1713 fixed on the assign path, in the function next door.
- **Moving an AMS to the other nozzle re-selected the K profile for the wrong one** — When a Filament Track Switch moves an AMS between inlets, the slot's K profile is re-selected for the nozzle it now feeds. It was resolved against the printer's first nozzle rather than the one the AMS had just been moved to, which on a machine with two different sizes fitted is the wrong nozzle by construction.
- **Reading a printer's calibration table could stall for 20 seconds** — H2-series firmware answers only the first one or two of a concurrent burst of calibration requests and silently drops the rest, each dropped request costing a five-second timeout before its retry: measured at 11 and 23 seconds on an H2C and an H2D for four parallel requests, against roughly one second sent in series. Sizes are now requested one at a time, while the printers themselves are read in parallel since separate machines are separate connections. An X1C answers all four at once, which is why this only ever surfaced on dual-diameter printers.
- **A print archived without its 3MF never got its timelapse, and on a short print could be given somebody else's (#2957 follow-up, reported by @doncaruana)** — The reporter confirmed the #2957 archive recovery worked and then noticed the timelapse was not recovered with it, "even though it's there". `_capture_timelapse_baseline_at_start` says in its own docstring that it must be called from every `on_print_start` path that proceeds to a real print, and what breaks otherwise: the completion scan falls back to snapshotting the card *after* the printer has written the video, so the new file lands inside the baseline and no diff can ever match. `on_print_start` has three such paths and the no-3MF fallback branch was not calling it — nothing else covered the gap either, because `on_print_running_observed` is restart-recovery only and is suppressed whenever `on_print_start` fires. So every fallback archive reached completion with no baseline in memory and none on the row, and kept its timelapse only by accident. Not confined to the reported cool-off case: the same branch serves the internal-storage verdict, so every H2C/H2D/P2S print that Bambu Studio's Print button sends to eMMC lost its timelapse the same way, on a card that was holding it the whole time. Measured on a live install before the fix: 0 of 9 fallback archives had a baseline, against 73 of 275 normal ones. The branch now takes the baseline like the other two, placed last as they are so a slow card cannot delay the active-print registration, the energy reading, the archive-created event or the start notification ahead of it. **The second half is the short-print case.** Under the five-minute FTPS cool-off the card is still unreadable when the baseline has to be taken, and `list_files_async` answers `[]` when its connect fails rather than raising — indistinguishable from a card holding no videos. When the cool-off then expired inside the 900-second poll window, every video on the card read as new, the first in listing order won, and a stale unclaimed video was attached to this print and deleted off the printer. The empty baseline is still recorded rather than refused, deliberately: Bambuddy deletes each video once it is attached, so the usual card holds exactly one video at completion and an empty baseline resolves it correctly — refusing outright would have lost that common case to protect a rare one. Instead the scan marks such a baseline untrusted and the attach step declines to *choose* between several candidates, leaving them on the printer for the manual Scan for Timelapse button. Five regression tests covering both halves and the single-video case; full backend suite green (11230).
- **Load and Unload in the AMS slot menu did nothing on a printer with a Filament Track Switch** — The command that loads a slot carries an optional `extruder_id`, and Bambuddy never sent it. That is correct on every printer without the switch, and it is what BambuStudio does there too: each AMS is wired to one hotend, so the firmware works the target out for itself and an explicit value would only be a guess at something it already knows. Fit the switch and that stops being true — every AMS is then bound to one of the switch's two *inlets* instead of to a hotend, either hotend is reachable from any slot, and a load command that names neither leaves the firmware with nothing to act on. It was dropped in silence. Bambuddy now asks which nozzle to feed, in the same place and on the same terms as Bambu Studio: a small dialog with the two hotends, no preselection so an absent-minded Enter cannot feed the wrong one, and the hotend already fed from that very slot greyed out. Printers without a switch are untouched and still load in one click, because the field has to stay absent there. If the switch has been fitted but not yet set up on the printer — any AMS still not assigned to an inlet — the load is refused up front with a note to finish Manual AMS Setup, rather than sending a command that would go nowhere. Unload was addressed at the same time and for a related reason: it was aimed with the printer's single `tray_now` field, which names one slot for the whole machine, so on any dual-nozzle printer with both hotends loaded it unloaded whichever of the two that field happened to name regardless of which slot's menu was used. It now names the slot, and on a printer that has reported two hotends the one actually fed from that slot is resolved from the per-hotend loaded-slot report; a slot no hotend is holding is reported as such instead of unloading something else. Single-nozzle printers are untouched — with one hotend `tray_now` is already unambiguous, so there is nothing to resolve and nothing to get wrong. AMS-HT slots are untouched too: an HT unit is addressed by its unit id rather than by AMS-and-slot, which these commands do not accept, so unloading one still falls back to the printer-wide form it always used.
- **A batch order whose queued runs were deleted became a card that could neither be queued nor closed (#2960)** — An order queues the runs it owes by cloning an existing queue item for the same plate: that row is the only record of the printer target, AMS mapping, filament overrides and print options the user chose, and there is nothing else to copy them from. Deleting it left the order still reporting the run as outstanding, with **Queue remaining** answering "Plate 1 has no queued or finished run to copy settings from" every time, and no way out — the Cancel action was hidden unless the order had pending items to cancel, which by then it had none of. Queue a multi-plate file, change your mind, remove the rows, and the Batches tab kept a card that did nothing forever. Deleting an order's last surviving run for a plate now cancels it instead: a cancelled run does not satisfy a target, so the order still says it owes the print, and it can still produce it. That is the design the feature already documented for cancelled runs — the delete path simply never took part in it. A run that has *completed* is exempt and is still deleted outright, because rewriting a finished run as cancelled would falsify what the order actually produced, and a plate with any other surviving run is untouched, so this only ever engages on the last one. The queue and the Clear History action both say what happened rather than reporting a delete that did not occur. Independently of that, the card no longer offers actions that cannot work: the response now reports, per plate, whether anything remains to clone from, so a stranded plate explains itself instead of presenting a button whose only outcome is an error toast, and the header button offers only the runs that can actually be queued. Dispatching an order with one stranded plate now queues every other plate instead of aborting the whole order on the first one it cannot clone — an explicit single-plate request still fails loudly, and names the plate the way the Batches tab does rather than by a bare index. Cancel is offered for any active order, since closing one out is exactly what an order with nothing left pending needs. Existing stuck orders are not rewritten: they report honestly that their runs cannot be re-queued, and Cancel now closes them. Translated in all 13 locales, with backend and frontend regression tests including the delete-then-dispatch path this began as.
- **Picking a spool near the bottom of the label dialog could scroll the dialog itself out of view (#2918, found and fixed by @whitigol)** — The modal panel was `overflow-hidden` under a 90vh cap, which makes it a scroll container even though nothing was meant to scroll there. When a checkbox low in the spool list took focus the browser scrolled every scrollable ancestor to bring it into view, and the panel obliged by scrolling its own header and print buttons off the screen. It now clips instead of hiding — the same clipping, but not scrollable — so the only thing that moves is the spool list, which is the part that is supposed to.
- **A fully transparent spool printed a label with no QR code (#2918, found and fixed by @whitigol)** — The colour swatch is drawn from the spool's RGBA, and an alpha below opaque puts the PDF into a transparency state that was never turned off again. The QR code is drawn immediately after the swatch, before any other colour is set, so it inherited that alpha: a spool saved at alpha 0 produced a label whose deep-link QR was invisible, and therefore unscannable, while the rest of the label looked correct. The swatch now draws inside a saved graphics state so its transparency ends with it.
- **The K value on an AMS slot card went blank after a while, and came back after a backend restart (#2854)** — H2-series trays carry no K value of their own; they report a calibration index, and the number on the card is resolved from that against the printer's calibration table. The printer answers that query per nozzle diameter, and it answers whoever asks — BambuStudio's queries arrive on the same topic Bambuddy listens to — but every response was stored as though it were the whole printer's table. The nightly GitHub backup asks for 0.2, 0.4, 0.6 and 0.8 in turn and finishes on 0.8, which holds nothing on a 0.4-plus-0.6 machine, so the table was left empty and every K value on the card disappeared until something happened to refill it. Responses are now filed under the nozzle they describe, so an empty answer for a size the printer does not have clears only that size. The same change fixes the three spool-assignment paths that look a calibration index up by nozzle diameter and had been quietly finding nothing whenever the last response was for a different nozzle.
- **A slot could show the other nozzle's K value, and a nozzle that had been swapped out could keep showing its own** — Calibration indices are numbered per nozzle, so index 16 exists on each and means something different on each. The card's WebSocket updates keyed on the index alone, so whichever nozzle's table was read last won the slot; only the first render, which comes from a different code path, got it right. Both now resolve a slot through the extruder it feeds, and fall back to which nozzles are actually fitted when that is not enough to single one out. Where neither settles it the card shows nothing rather than a confident wrong number.
- **A fresh Bambuddy showed no K values at all until someone opened the Profiles page** — Nothing read the calibration table when a printer connected. It arrived by luck: a visit to Profiles or Configure Slot, a GitHub backup, or the printer answering somebody else's query. It is now read once per connection, asking only for the nozzle sizes actually fitted.
- **Turning Spoolman mode on deleted every built-in slot assignment, and turning it back off did not restore them (#2812, reported by @chitrangdesign)** — The toggle ran an unfiltered `delete(SpoolAssignment)` across every printer. Switching straight back cleared the *other* table instead, so the two directions were symmetric in code and one-way in effect, and the setting auto-saves on a 500 ms debounce with no save button and no confirmation. Opening the settings page to see what the option did was enough to destroy the configuration — the reporter's log shows four toggles in 85 seconds and the assignments never came back. The deletion had a real reason: checks that read both assignment tables would otherwise let a row in the mode you are *not* using answer for the mode you are, which is how #1473 was fixed. That reason has moved to where it belongs: every place that could be confused now asks which mode is active rather than reading whichever table happens to have rows, and nothing is deleted on a toggle. Each mode keeps its own assignments, so switching is reversible and inspecting a mode costs nothing. Six sites needed it, and only two are reads you would guess at. The per-slot K-profile lookup consults the built-in table first and, on a hit with no matching profile, deliberately stops rather than falling through to Spoolman, so a leftover row would have shadowed the Spoolman binding — the symptom #1556 reported from the other direction. Configuring a slot *writes* its K-profile against whichever table answers first, so the same leftover would have filed a calibration against a spool the printer is not using and never written the local one, leaving a calibration that appeared to succeed and then did not apply. And the auto-unlink pass that drops an assignment whose tray no longer matches its fingerprint ends in a delete: left ungated it would have removed the preserved rows one slot at a time as the AMS contents changed under the other mode, undoing the whole point of this more slowly but just as completely. The remaining two are the missing-assignment notification and the queue cost estimate. A sixth, the built-in remaining-weight fallback inside the Spoolman AMS sync, is deliberately left inert: it was unreachable while the table was being emptied, it is keyed by slot rather than by spool, and `create_spool` writes `remaining_weight` unconditionally, so waking it up would seed a stale figure into a brand new Spoolman spool. Existing installs need no migration: their inactive table is already empty, because it was being emptied.
- **A print that could not debit a spool said nothing about it (#2812)** — This is what turned the toggle above from an annoyance into lost filament. A print whose assignments existed at print start and were gone by the time it finished resolved its 3MF, read its per-filament grams, resolved its tray, and then skipped the debit because the row no longer existed — logged at INFO, invisible under the default log level, with the completion notification firing as usual. 65.49 g was never deducted and the reporter only noticed because a spool's remaining weight looked wrong. The skip is now a warning that names the grams, and a completed print that failed to charge a tray it drew from raises the missing-spool-assignment notification, not only the print-start check that runs before the job and was correct to stay quiet. The two are different statements — the first says the weight may not be tracked, the second says it was not — so a print warned at start may notify twice, which is the right trade. This is independent of the toggle and catches any other cause of an assignment disappearing mid-print.
- **Print cost ignored the linked Spoolman spool's price and always used the default rate (#2591, reported by @khaosdoctor)** — Spoolman holds per-spool pricing, and #261 gave that as the reason for integrating with it, but nothing ever read it. A print's cost is set once, at archive time, from the built-in Filament catalogue matched on the *primary* type, falling back to a global default rate — and in Spoolman mode nothing revisited that figure afterwards. The per-spool recompute that would have fixed it runs only over rows the built-in inventory writes, and Spoolman mode hands the usage tracker `spoolman_owns_usage` at print start so it writes none. On an install with an empty catalogue, which is what the reporter had, every print came out at the default no matter what the spool cost. Multi-material was wrong twice over: the primary type's rate was applied to the whole print's weight, so a slot of expensive PA was billed at the price of the PLA beside it. Each slot is now priced from the spool it was actually charged to, as the charge is made, and the per-slot costs are summed — which is what fixes the multi-material case rather than a separate change. The rate is the spool's own `price` when set, else the filament's, divided by `filament.weight`; that is the same net-grams field the remain%-delta path already divides by, so a spool that can be charged by percentage can always be priced. A missing or zero price is read as unpriced rather than free, and those grams — along with a tray that has no Spoolman row and any filament the sliced file never attributed — are covered at the default rate in one subtraction against the archive's own total, so a partially priced print still reports a whole-print figure instead of a fraction of one (#1344 in the other inventory mode). Only the first run writes the archive's cost, matching the built-in writer (#1378). Pricing is applied even when the slot-to-tray mapping was a positional guess, unlike the colour and material rewrites beside it: those overwrite what the slicer recorded, whereas the grams have already been deducted from these spools and the archive should say what that deduction was worth.
- **A rescan or a cost recalculation quietly replaced a Spoolman-derived cost with a default-rate one** — Both rebuild an archive's cost from the built-in inventory's usage rows and fall back to the catalogue or the global default when there are none. In Spoolman mode there are never any, so the fallback was not a recalculation but a downgrade, and it would have undone the pricing above on the next rescan. The spool-to-slot resolution a price is derived from only exists while a print is being completed and cannot be rebuilt from the archive row afterwards, so both now leave a cost alone rather than replacing it with a worse one, and the bulk endpoint reports how many it kept. An archive that has no cost yet is still priced, and with Spoolman off both behave exactly as before.
- **A print sent from Bambu Studio charged the wrong Spoolman spool, and rewrote the archive to match (#2953, reported by @bitelvl1)** — A sliced file numbers its filaments 1..4; which AMS tray each came from is decided when the job is sent, and #2768 gave the Spoolman writer two ways to recover that decision when the print did not come through Bambuddy: the printer's own `mapping` field, and a colour match of the 3MF's slots against the loaded trays. An A1 can satisfy neither. It publishes no `mapping` field, and it drops the MQTT connection when Bambuddy subscribes to its request topic, so the slicer's instruction never arrives either — which leaves the colour match, and that compares hex strings exactly. The reporter sliced with a generic black profile against a tray they had set to #111111. No match, so every print fell through to the positional default and charged slot 1 to whatever sat in the first tray: 2.17 g onto a grey PLA+ spool, while the print was fed from tray 3. The printer had already said so. `Tray change during print: tray=3 at layer=0` is in their log, recorded 90 seconds in, and read further down the same completion pass to decide which slots the print had touched — the same pass then charged tray 0 on a guess. A single-slot print is now pinned to the tray the printer reported feeding from, using the ladder the built-in inventory writer has consulted all along: the mid-print tray-change log, then the tray loaded at print start, then the current one, then the last real tray seen. Spoolman users were the only ones not getting it, which is why an install with the internal inventory in use never showed this. Gated on exactly one slot with usage, because a multi-colour print moves `tray_now` on every change and one reading cannot then be attributed to one slot; and it declines when the log holds more than one switch, so an AMS-backup runout still splits per segment (#1793) rather than landing whole on one spool. Where nothing at all names a tray the positional default still stands — it is right for an AMS loaded in slicer order — but it now says so at warning level, and no longer restamps the archive's filament colour and material from a spool it picked by position. That restamp is what made the fault look like data loss: the grams can be put back, whereas overwriting what the slicer recorded leaves nothing to compare against, and the reporter's archive had already been rewritten from #000000 to the wrong spool's grey.
- **One unexplained disconnect could stop a printer reporting slicer print mappings for good** — Bambuddy subscribes to the printer's request topic to intercept the mapping a slicer sends with a print. Printers that refuse kill the TCP connection instead of returning a SUBACK failure, so the only signal is "we subscribed, then got disconnected", and that was believed the first time it happened. Every other reason a connection drops inside the same few seconds looks identical — a network blip, the printer rebooting, the container being stopped mid-probe — and the verdict was cached per serial with no re-probe anywhere, so on a printer that supports the topic perfectly well a single unlucky drop cost mapping capture for the rest of the process, and every Studio print after it was charged to a spool picked by tray position. The subscription is now retried once before being written off, and a disconnect Bambuddy asked for is not counted at all. A printer that genuinely refuses answers the same way every time and pays one extra reconnect; nothing changes for one already known to refuse, which still skips the subscription outright rather than reopening a reconnect loop.
- **The AMS temperature alarm fired hourly on ambient room heat, and silencing it cost the colour band (#2905, reported and contributed by @ojimpo in #2943)** — `ams_temp_fair` decides when the AMS card turns amber, and it decided when a notification was sent as well. 35 °C is a reasonable place to change a colour and a low place to page someone: an AMS in a room without air conditioning sits above it on ambient heat alone, so the alarm repeated every hour for as long as the weather lasted — with every heater target at zero and humidity inside the good band — and the only way to stop it was to raise the display band and lose the red that says the unit is warm. "Red above 35 °C, notify above 45 °C" was not expressible. A new **Alarm above** field under Settings → AMS Display Thresholds separates the two. It is nullable and unset resolves to the fair value, so an install that never touches it behaves exactly as it did and there is no migration — the field is a row in the existing key/value settings table, not a column. Deliberately not a per-filament map like the humidity thresholds: that map exists because humidity also decides when a drying cycle starts and PLA, PETG and PA want different targets, whereas ambient heat does not care what is loaded, so a per-type map would only help someone who set every type to the same number. Three call sites take the new value, not one. The comparison is the obvious one; the second is the drying latch, which releases once the unit reads back at or below the threshold it was given, so handing it the display band stranded the latch on any unit that settles above it — an AMS resting at 37.7 °C never returns under a 35 °C band, so the hold placed during a cycle could only expire on its grace cap instead of releasing when the unit had actually cooled. The third is the number the notification quotes, or the message reads "> 35 °C" while firing at 45. A non-positive or non-finite value is refused rather than honoured, which matters more than it looks: nothing is ever greater than NaN, so a value that failed to parse as a real number would have silenced the alarm permanently — the failure mode that looks exactly like a working configuration. The field shows the fair threshold as its placeholder so the fallback is visible without reading docs, says so when a value it cannot use is entered, and the temperature block gains the disclosure line the humidity block has had all along: only the alarm threshold notifies, Good and Fair colour the display, and leaving it empty alarms where it always did. The background task that dispatches these alarms is a no-arg infinite loop and had never been covered by a test; it is now driven end to end, pinning that an install with no value set still fires and quotes 35, that a stored 45 is both what fires and what the message says, and that 37.7 °C in a warm room sends nothing.
- **A clear spool synced to Spoolman as pure black (#2912, reported and contributed by @ojimpo in #2924)** — The AMS reports a translucent roll as `00000000`, and every write to Spoolman truncated that to six characters before storing it, so a PETG Translucent spool arrived as opaque black and the external catalogue then named it "Black". Spoolman's own schema accepts eight characters, so the value Bambuddy was discarding was one the backend would have taken verbatim. #1545 fixed exactly this for the built-in inventory and left the Spoolman path behind, which is why internal mode has been storing the alpha correctly for months. The read side was the matching half: it rejected anything that was not exactly six characters, so fixing the writes alone would have turned clear spools grey instead of black. Eight characters are stored only when the alpha byte says the filament is genuinely translucent — passing everything through would rewrite the colour of every opaque spool on its next touch, churning records in people's Spoolman for no benefit. Colour comparisons now key on the shape a value would be stored as, so two colours match exactly when storing them would produce the same value. That is what keeps the widening safe in both directions: an opaque tray still finds the six-character filaments every existing instance is full of, so no upgrade mints a duplicate for every spool on the next sync, while a clear roll gets its own record instead of being conflated with the black one of the same RGB. The edit route compares the same way, so a no-op edit no longer PATCHes the filament on every save and an alpha-only edit still reaches Spoolman. One consequence is worth stating: a filament already stored wrongly-opaque by this bug gets a second, correct record the next time that roll is auto-added, rather than the old one silently capturing every clear spool that follows.
- **Translucent spools showed as an empty circle or as solid black in four more places** — The swatch helper had two answers, the transparency checkerboard for a fully clear colour and a flat fill for everything else, so a half-translucent spool rendered identically to an opaque one. The AMS tray swatches never reached that helper at all: Assign Spool painted the reported colour directly, so a clear tray was an invisible circle, and Configure AMS Slot cut the alpha off first, so a clear tray was solid black — the same symptom as the sync bug above, in the UI, and present regardless of which inventory mode is in use. All of them now draw through one helper, which lays a partly translucent colour over the checkerboard so the swatch shows both the tint and that it is see-through.
- **Every notification provider vanished from the list after the inventory toggles were wired up** — Adding `on_stock_reorder_alert` and `on_stock_break_alert` to the provider schema made them required on the way out as well as the way in, because the response model inherits the write model. Every `on_*` column on `notification_providers` is nullable with no server default, and on an install where the table had already been created from the ORM metadata before migrations ran, the `ALTER ... DEFAULT false` that introduced those two columns was swallowed as a duplicate and never backfilled the rows that were already there. Those NULLs sat harmless for as long as nothing read them; the moment the flags were declared on the response, the row failed validation, and since a list is validated as a whole, one such row took every provider down with it. The API returned a 500 and the UI rendered what it was given — an empty list — so correctly configured providers looked deleted while sitting untouched in the database. They are backfilled to off on the next start, matching what the sender already did with them: it selects providers with `IS TRUE`, so a NULL flag never sent anything. A NULL flag now also reads as off rather than failing the response, so the next flag added to that schema cannot repeat this.
- **Two inventory notification toggles could never be turned on, so stock alerts have never been able to fire (#2945, reported by @ojimpo; regression tests contributed by @ojimpo in #2956)** — `on_stock_reorder_alert` and `on_stock_break_alert` exist as columns on a notification provider, have their own templates, and `notification_service` looks providers up under exactly those names before sending. The whole UI is there too: a toggle in Add/Edit Notification, a badge on the provider card, the field in the API client's types, and tests for all of it. The one thing missing was the schema. `NotificationProviderCreate`/`Update` never declared either field, and Pydantic drops what it does not declare, so every request that carried them came back `200 OK` with the row unchanged — and `_provider_to_dict`, which is a hand-maintained field-by-field map, never returned them either, so the toggle read back off no matter what the database held. Nothing errored anywhere along that path. Both directions are wired now, and the round-trip tests that already covered the Home Assistant toggles cover these too, because the failure is structural rather than particular to one field: any column missing from those two maps is invisible to a test that builds providers through the ORM, and only a create-then-re-read through the route catches it. This makes the setting stick and report itself honestly; what actually sends the alerts is the #2955 entry above. The tests no longer depend on anyone remembering the next field, either. They now read the list of provider fields from the database model itself, so a toggle or setting added later is checked from the day its column exists, with no new test to write. It must be accepted when a provider is created and when it is edited, and every toggle must survive a save and read back from both the single-provider and the list endpoint. Each toggle is set to the opposite of its default for that check, because nine of them default to on and a test that saves "on" and reads back "on" would pass even if the value were silently dropped.
- **One Home Assistant sensor reporting a long text state could stop every printer sensor from updating** — `last_state` is a 64-character column, and the poller wrote whatever Home Assistant returned straight into it. A numeric entity that starts answering with free text - an enum, an error string from a template sensor - overflows that. SQLite stores it regardless, which is why this stayed quiet, but PostgreSQL rejects the row, and a poll pass commits every sensor at once: one such entity took the whole batch down on every tick, so no printer sensor's reading, timestamp or alert state advanced again, and the print interlock kept deciding against a frozen picture. What is persisted is now cut to the column, while the cached reading keeps the full state for display. The comparison that decides whether the state changed is made against the cut form too - comparing the stored value against the raw one would read as a difference on every single poll and churn `last_changed` forever. The storage-location poller was fixed the same way in the same release; both now go through one helper, each passing its own table's width.
- **Configure Slot could bind the default K value for a profile the picker was visibly showing** — The AMS slot dialog sends `cali_idx` from `selectedKProfile`, which the mutation read through its own closure. React Query hands a mutation its options from an *effect*, so a click landing between a commit and that effect flushing runs the previous render's function - one that captured the selection as it was before the K-profile query resolved. The result is `cali_idx: -1`: the printer binds the default 0.020 rather than the calibrated K, while the dialog shows the right profile selected the whole time. It surfaced as an intermittent failure of the per-nozzle K-profile test, roughly one full-suite run in six, and reproducing it with staggered query resolution showed the divergence directly - the select element held the correct profile immediately before and after the click, and the payload still carried -1. That test's slot is the most exposed case in the file, a right-hotend slot carrying the left hotend's index, where the "keep showing the active profile" safety net cannot repair an empty recompute. The mutation now reads the selection from a ref written during render, so it resolves at execute time rather than at capture time; the same applies to the K value and the profile's ids, which travel in the same payload and had the same exposure. Measured over 27 runs of a staggered-resolution grid: 2 failures in 15 before, 0 in 12 after. The modal's printer-model query was also missing from the test file's mock, so it ran with no query function and rejected on every test in it - mocked now, though on its own that changed nothing, which is how the ref was confirmed as the fix rather than assumed.
- **A print started from the printer's own screen swept every FTP path, archived blank, and then blamed a slicer setting (#1820, reported, captured and re-measured across four daily builds by @ojimpo)** — `current_project_url` was assigned in exactly one place, `_handle_request_message`, and that runs only for the request topic. A print started from the touchscreen publishes nothing there, so the field stayed empty for the one case the storage verdict exists for: the file is already in the printer's own model library under `/userdata/model/history/`, and port 990 does not serve it. The verdict then fell through to the `sdcard` flag — which @ojimpo's H2S reports as true, its "card" being the internal eMMC — so every such print ran the full sweep before giving up: 16 filename-and-directory attempts over 22 FTPS connections, 18 of them refused, 6.4 seconds, then a fallback archive with a name and nothing else. The printer does announce where the file lives. It arrives as an unsolicited `project_file` **response** on the report topic about two seconds before `gcode_state` reaches PREPARE, and Bambuddy now reads the `url` off it, gated on `result: SUCCESS` and a non-empty value so a refused dispatch cannot name a file that was never written. Reading it there rather than only at the request topic also covers an install nobody had in view: some brokers refuse the request-topic subscription, and on those no print of any kind had ever populated the field. Our own dispatch is echoed on both topics, so the new branch captures state and nothing else — the "external dispatch" diagnostic stays with the request-topic handler, which sees the echo first, and reusing it here would have logged every Bambuddy-started print as somebody else's. What the print names is now what gets tried: the five directories a copy could be in, rather than the ~110 connections that cannot succeed. That copy is worth trying, because an H2S keeps recently used jobs under `/cache` and archives them in full while they last; roughly eight files later the same job archives with a name only, which is exactly what @ojimpo measured on two prints the same day. Nothing about slicer-sent prints changed. **The banner that appears afterwards no longer describes a step that never happened.** With no reason recorded, a blank archive fell back to the original wording — "Store sent files on external storage" is off in your slicer, go and turn it on. On the reporter's printer that setting is on, and the internal-storage wording added in #2780 already explains why it would not help on an H2 anyway, so the archive that most needed that explanation was the only one that could not be given it. Prints started from the screen, from Handy, or by picking a file the printer already had now carry a reason of their own and their own wording: no slicer was involved, nothing was sent, and no setting changes it. What can be done instead is offered in its place — start the print from Bambuddy, or read the sliced weight off the printer's own file browser and enter it under **Filament used (g)** in Edit Archive, which the cost and the Projects totals then follow. Settings > Printers > Connection Diagnostic reads the same reason rather than a fixed one, so the two surfaces cannot end up giving the same printer different advice.
- **A print that started during an FTPS pause was archived empty forever, even after Bambuddy downloaded the file (#2957, reported and diagnosed by @doncaruana)** — A failed TLS handshake puts a printer's file service in a five-minute pause, and the archive flow checks that pause at the top of its path loop and gives up before opening a connection. @doncaruana's P1S started a print inside one: Bambuddy wrote an empty fallback archive 13 milliseconds after print start, having never touched the network. Four minutes later the pause cleared, the cover endpoint downloaded the very same file — all 8,956,942 bytes — parsed it, took the plate-2 thumbnail out of it, and published it to the shared 3MF cache under the exact key the archive flow looks up. Nothing ever looked. All three readers of that cache run before or during the print-start handler that had already given up, and print completion drops the cache as its first act, deleting the file. So the archive stayed a shell — no thumbnail, no file size, no filament, no layers — for a print whose source Bambuddy had held, parsed and indexed, and there was no path in the codebase by which a fallback archive could ever become a real one. Two things change. A 3MF that arrives later is now offered to the running print's archive, which fills the existing row in place rather than adding a second one — the row's id is load-bearing, with the energy reading, the timelapse session and the start notification all written against it. That covers this report at no network cost, because opening the printer card already downloads the file to draw its thumbnail. And a fallback created *because of* the pause now schedules its own bounded retry, spending the cache first and the printer only if that misses. The retry is deliberately not scheduled for the other reason an archive comes up empty: a print the printer kept on internal eMMC has no FTPS copy to come back for, and retrying it is the sweep that was removed in #2780 — so the two causes are now recorded separately instead of both landing as "no 3MF". Recovery refuses anything that is not a readable 3MF, since a truncated download would replace an honest empty archive with wrong metadata, and leaves an archive alone once it has a real file. The last opportunity is taken too: print completion spends the cache on a still-empty archive before evicting it. Wiki updated.
- **A printer whose failure detection was not working showed a green "Safe" badge (#2952, reported via in-app bug report, with a full ML API and Bambuddy log correlation)** — The printer card's AI badge collapsed every class that was not Warning or Failure into green **Safe**, and the service reported `safe` whenever it had no verdict to give. The state entry is created the moment a monitored print is seen, which is before the first snapshot and well before the first inference, so a rejected ML API token, an unreachable ML API, a camera that never yielded a frame and an unset External URL all rendered identically to a healthy watched print: a green pill reading Safe at score 0.000. For a safety feature that is the worst available failure mode — it asserts the print is being watched at exactly the moment it is not. The reporter read that badge, concluded the detection loop had never started, and spent an evening ruling out the network path; the loop had in fact been calling the ML API every ten seconds the whole time and being turned away with a 401. What hid it is that Obico's auth layer rejects a bad token before its own request log records anything, so a token problem and a service that never runs leave the same trace in the ML container: nothing. Successful checks log nothing there either, which leaves the container log unable to distinguish working from broken in either direction. The badge now has two more states and tells the truth in both: **Not checking** (amber) when the last check produced no result, carrying the reason — rejected token, unreachable API, failed capture, missing External URL — in the tooltip and the modal, and **Starting** while a monitored print waits for its first result. Score and frame count are withheld while a printer is not being checked, since 0.000 next to "Not checking" reads as a measurement rather than the absence of one. The reason is tracked per printer rather than service-wide, so on a multi-printer setup a card names its own problem instead of whichever printer failed most recently; it stays behind `settings:read` because it can quote configured URLs, while the badge state itself does not, because whether a print is being watched is not configuration. An unrecognised class from a future backend now falls back to **Starting** rather than **Safe** — the fallback that caused this. Also fixed alongside: **Test Connection** now saves the form before probing, so a green "reachable and healthy" always describes the configuration the detection loop is actually running with rather than what is typed in the boxes. The wiki's badge table and troubleshooting section are updated, including the ML API container log being unable to answer this question.
- **Bambuddy could not start against a PostgreSQL server whose messages are not in English (#2949, reported and diagnosed by @dvb6666)** — @dvb6666's Debian PostgreSQL 15 answered the first migration statement with `столбец "parent_run_id" отношения "pipeline_runs" уже существует` and startup aborted. The column already existing is the *expected* outcome there: `create_all()` builds the tables from the models before the migration list runs, so on a fresh database essentially every `ADD COLUMN` in that list is a duplicate by design, and all 382 of them relied on the runner recognising "already applied" and moving on. It recognised it by searching the error text for `already exists` — but PostgreSQL renders its messages in the server's own `lc_messages` locale, and a Russian server does not say that. So the very first statement was re-raised as a fatal error. This was never specific to `parent_run_id` or to one PostgreSQL version, which is why @dvb6666 got the identical failure after moving from 15 to 18: no PostgreSQL server outside an English locale could start Bambuddy at all, fresh install or upgrade, and the failure surfaced as a schema error rather than as anything pointing at the language. Idempotency is now decided by SQLSTATE, the five-character code PostgreSQL never translates — `42701` duplicate column, `42P07` duplicate table or index, `42710` duplicate constraint or trigger, `23505` duplicate key. The narrowing that was already there is kept and now rests on a code rather than a phrase: a missing column counts as "already applied" only for `RENAME COLUMN`, so a column missing during `ADD COLUMN` or `CREATE INDEX` still aborts startup instead of quietly hiding a corrupt schema — as do a missing table, an unknown column type, and a syntax error, each verified against a live server. SQLite keeps the text match, since its driver publishes no SQLSTATE and it never localises its messages. The same treatment went to the second place that read message text, the OIDC auto-link safety constraint, where a mistranslated "already exists" would have failed startup on the same servers. Measured rather than reasoned about: against PostgreSQL 15 under `ru_RU`, `en_US` and `C`, a full `init_db()` completes on both a fresh database and a re-run, and the schema the Russian server ends up with is byte-identical to the English one — nothing is being silently skipped to make startup succeed.
- **A test in the plug-energy suite failed for 31 minutes of every day (#2938, reported and measured by @ojimpo)** — `test_nothing_derivable_before_the_first_midnight` passed for 23.5 hours a day and failed for the other half hour, which is the shape that reads as ordinary flakiness and gets re-run rather than fixed. @ojimpo hit it running the full suite for an unrelated PR at 22:10 UTC, stashed his branch to confirm it reproduced on clean `dev`, then measured the window minute by minute against the real day boundary instead of guessing at it. **The test's premise, not the product.** It asserts that nothing can be derived when a plug's only snapshot was taken *after* this local midnight, and it placed that snapshot at a raw wall-clock offset — `now - 30 minutes`. The comment beside it, "taken this morning, after midnight", is the premise, and it is only true away from the boundary: for the first half hour of each local day, `now - 30 minutes` lands *before* local midnight, where it is a perfectly good baseline. `_counter_at` finds it, and `today` comes back `103.5 - 102.0 = 1.5` instead of `None`. The window is local 00:00–00:30, which is 22:00–22:30 UTC while CEST is in effect and 23:00–23:30 under CET — it moves with DST, because the module pins `Europe/Berlin` in an autouse fixture and an outer `TZ` makes no difference to it. **`derive_today_yesterday` is right and unchanged.** A snapshot from before local midnight genuinely *is* a valid baseline for today, and the production code treating it as one is the documented behaviour, so nothing here reaches a running install. The snapshot is now anchored to `local_day_start(now) + 30 minutes`, which is the idiom the file's other nine snapshot writes already use and the reason none of *them* could drift — this was the only one that offset from the wall clock, and the only one that failed. Verified by replaying 5760 minutes across four days including both DST switch days: the old expression fails 31 minutes per day, the new one fails none. **Scope.** One test file. No product code, no DB migration, no schema change, no permission, no i18n change.
- **The slice dialog took settings from the file with "Use the file's built-in settings" switched off (#2942, reported by @zevulos)** — Two separate features read as one. The checkbox slices a 3MF the way its designer set it up, ignoring the picked profiles; the per-option "from file" ticks beside each setting carry the designer's individual deviations onto the profile you picked (#2622), and those arrived pre-ticked whatever the checkbox said. So a slice run deliberately *without* the file's settings still took sixteen values out of it — the reporter's own log names them, `enable_support` and `support_type` among them, landing on a process preset they had chosen on purpose. The ticks now follow the checkbox: off, nothing comes out of the file until it is asked for by name; on, every setting the file changed shows ticked, because on that path the file really does drive the whole slice. Taking the designer's work in bulk is still one click, from a line at the top of the settings panel that says how many settings the file changed — and it still leaves the machine-tuned ones and the two that *are* the picked preset for a per-key decision, which is the classification #2622 made and this does not widen. Two things underneath had to change for the checkbox to mean what it says. The panel greys out options the slicer's own rules switch off, and it was evaluating those rules against what the user had typed alone, falling back to the compiled-in schema defaults for the rest — so a preset with supports on read as `enable_support: false` and greyed out the whole Support page while the slice ran supports. A greyed row greyed its tick too, which is how the reporter's screenshot shows a support type marked "from file", applied to the slice, and impossible to clear. The rules now see what the slice will actually run with: the preset's values, the file's values for the keys that are on, and anything typed on top. And the tick is no longer gated on the slicer's rules at all, because it answers a different question — not whether an option is in play, but where its value comes from. Separately, the support carry-over from #1881 ran underneath the ticks entirely, lifting four support keys out of any 3MF that had supports on, with nothing on screen to decline. It now stands down for the keys that were offered and turned down, which the request can say for the first time: an empty `design_overrides` list means the caller was shown the file's settings and took none, where no list at all is a caller that predates the choice and keeps #1881 whole — as do sources that record no deviations to tick, an OrcaSlicer export among them. Covered by backend and frontend tests, and measured against the reporter's own sixteen keys. Note the practical change: a MakerWorld file with supports enabled no longer switches supports on for you — tick **Enable support** in the panel, or the checkbox above it, if that is what you want.
- **A colour mismatch was reported between two filaments the app itself called "Blue" (#2941)** — The print dialog compared a slicer profile's near-pure `#0028FF` against the Bambu navy `#0A2989` loaded in A4 and correctly said they differ: 118 apart in the blue channel alone, a CIEDE2000 distance of 15 where 1 is a just-noticeable difference. Nothing in the dialog said so. A hex that misses the colour catalogue is named by a coarse family bucket, so both sides resolved to the name "Blue", and the warning sat between two identical labels with no way to tell what it was objecting to — the reporter read it, reasonably, as the matcher being broken. Where both sides of a mismatch carry the same name they are now qualified by their hex, and the tooltip names them together: "Same type, different color: needs Blue (#0028FF), slot has Blue (#0A2989)". Names that already differ are left alone, since the hex is noise once the words separate them. The comparison itself is untouched — it was right, and its tolerance is not the kind of thing to widen on a single report: admitting a difference that large would start matching navy to cyan, and the eligibility rule is shared with the queue scheduler, which would then dispatch on it. The panel's own status line, the required-filament tooltip, the auto-matched/manual marker, the slot placeholder and the type-not-found message were all hardcoded English; they are translated now, in all thirteen locales, and two of them turned out to have had translations sitting unused in every locale file the whole time.
- **A print that failed on an `hms[]` fault recorded an unlookupable error code** — The queue's failure reason is built by formatting the fault's module and error into `MMMM_EEEE`, and that one derivation never masked the error to 16 bits. A fault arriving from the printer's `hms[]` array carries its alert level in the code's high half, so the label came out as e.g. `0500_3000A` — five digits in a group that has four. It is not a code the user can look up on Bambu's HMS index, and because it matches no catalogue key the sentence explaining the failure was dropped along with it, leaving the bracketed number alone. The nozzle-size mismatch behind #1111 is exactly such a fault: it reads as `[0500_4038] The nozzle diameter in sliced file is not consistent...` when the printer reports it one way and read as a bare `[0500_24038]` when it reported it the other. There was already a helper that gets this right and is used by the archive's own failure-reason lookup; the queue's now calls it instead of keeping a fourth copy of the derivation.
- **AMS slots were offered as places to store a spool** — The Storage Location dropdown in the spool editor listed entries like "H2D-1 - AMS A1" alongside real locations, and they could not be removed. They are not locations at all: Bambuddy used to record which slot a spool was loaded into by writing that string into Spoolman's `location` field, and although that writer went away when Storage Location became something the user picks, the strings stayed on people's Spoolman spools — where the location sync, which imports every distinct one it finds, has been reading them back ever since. A printer slot is where a spool is loaded, not where it is put away, and Bambuddy already tracks the first through slot assignments. Deleting one by hand did not work either, which is what made this a dead end rather than an annoyance: the delete route refuses a location that has spools, and in Spoolman mode it counts them by matching that same string, so every marker still sitting on a loaded spool answered 409 — and the two that were empty came back on the next sync a minute later. The import now skips them, and the ones already in the catalogue are removed on upgrade. The filter is deliberately narrow, matching only the shape Bambuddy itself wrote — an optional printer-name prefix followed by `AMS A1`, `AMS-HT A1` or `External Spool` — so "AMS Drybox" and "Spare AMS trays" are left alone; anything it swallowed would be a place the user could no longer file a spool under. A row is only removed when no spool in Bambuddy's own database points at it, by id or by legacy free-text name, so an internal-mode user who has deliberately filed spools under such a name keeps it. Spools in Spoolman are not touched: their location strings are the user's data on the user's server, and one that still reads "H2D-1 - AMS A1" in the inventory list is telling the truth about what Spoolman holds — it simply stops being offered as a destination.
- **A wood, silk or gradient roll the AMS added for you was drawn as a flat disc** — A spool's swatch is composed from `effect_type` and `extra_colors`, and the RFID auto-add set neither. It reads the colour catalogue to name the colour and took the name alone, even though the row it had in hand also carries those two columns — the spool form's own colour picker hands both to a spool a user adds by hand, so the same roll rendered one way when you typed it in and another when the printer identified it for you. Both columns now travel with the name. That alone would have changed nothing on a stock install, because the shipped catalogue carries an effect on none of its 600-odd rows, so the subtype is read where the catalogue has none: it is already derived from what the printer reports, and the two vocabularies line up — Wood, Silk, Sparkle, Marble, Glow, Galaxy, Metal, Rainbow, Translucent, Matte, and the Gradient, Dual Color and Tri Color that the M*/T* colour codes upgrade a subtype to. "Silk+" is read as Silk, since the plus is on the product name rather than the finish. A subtype that names no effect — Basic, Tough, CF — leaves the column empty rather than inventing an overlay, and a value already set is never overwritten, so the column stays what it is documented to be: a rendering hint the user can override without touching Bambu's categorical label.
- **Every Bambu RFID spool was added with the wrong empty-spool weight (#2909, reported and fixed by @ojimpo in #2923)** — A Bambu roll arrives on the 250 g Low Temp spool, but the lookup that gave an auto-added spool its `core_weight` asked for the first catalogue row whose name starts "Bambu Lab" and took whatever came back. There are three of them, and there was neither a matching step nor an `ORDER BY` to choose between them: SQLite hands back insertion order in practice, which is the 216 g High Temp row, and Postgres promises nothing at all once the table has seen an update. So the same roll was recorded 34 g light on one install and correctly on another, and nothing about the spool said which had happened. The row is now matched by name, case-insensitively so a re-typed entry still counts, with the lowest id winning if a user has two rows sharing a name — without that tiebreak the read does not merely pick badly, it raises and the AMS read fails outright. A missing or renamed row falls back to the documented 250 g rather than to another catalogue row, because falling back to a row is how the arbitrary pick started; an install that has re-measured its own spools gets its own number. The path also records *which* row supplied the weight, which it never did — and that column is not the cosmetic detail it looks like. The spool form does not leave it blank: it auto-selects whenever exactly one catalogue row matches the weight, and shows the first match's name otherwise, so an arbitrary tare was displayed as a named row and written back as that row's id the next time any field was saved. A wrong number was being laundered into what reads like a deliberate choice, which is why the column being empty was the *less* damaged state. The row name and the 250 g fallback are shared with the upgrade repair below rather than written twice, so a repair can never go hunting for a row the creating path does not write. Covered by backend tests, including the duplicate-name case that would otherwise fail hard.
- **The spool tare an RFID roll was added with is corrected on upgrade (#2909, diagnosed by @ojimpo)** — The lookup that gave an auto-added spool its `core_weight` asked for the first catalogue row whose name starts "Bambu Lab" and took whatever came back. There are three, and which is first is the database's business: SQLite returns insertion order in practice, Postgres promises nothing once a table has seen an update, so the same roll was recorded with the 216 g High Temp tare on one install and correctly with the 250 g Low Temp one on another. The forward fix picks the row by name; this repairs the rows already written, which the forward fix cannot reach. The tare is not cosmetic: a spool weighed on SpoolBuddy has its remaining filament worked out as the scale reading minus the tare, so a 34 g low tare credits the roll with 34 g that is not there and writes a used weight 34 g short. That error is a constant — every later print adds to the used weight on top of it — so adding the difference back is exact however much has been printed since, and it is applied only to spools that have actually been on the scale; one that never was has a used weight derived from the AMS remaining percentage, which the tare never entered into. The rows to repair are identified by the signature of the broken lookup — added by RFID, carrying the weight of one of the *other* Bambu catalogue rows — with the weights read out of the catalogue rather than hardcoded, so an install whose rows have been re-measured is repaired to its own numbers. One case cannot be told apart and is stated rather than hidden: someone who moved an RFID roll onto a genuine High Temp spool and set 216 g by hand looks identical to a row the lookup got wrong and is normalised with them. Keying on whether a catalogue row had been recorded would not have rescued them either — the weight picker auto-selects the only row matching the weight and writes its id on the next save, so that column says only whether the form was ever opened. Runs exactly once, so a tare set afterwards is kept.
- **A wood-filled spool was named as plain PLA on the slot it was assigned to** — A spool's subtype is half of what it is called: "PLA" and "PLA Wood" are different filaments, and the AMS slot's hover card built the assigned-spool line out of brand, material and colour name with the subtype left out. A roll of Bambu PLA Wood Classic Birch in an H2C's A4 was therefore announced as "Bambu Lab PLA - Classic Birch". Everything else named it correctly at the same moment — the RFID read, the inventory row, the slot's own profile line, which is built from the spool's slicer preset rather than reassembled, and Bambu Studio — so the one wrong line read like a bad tag read rather than a display fault. It was not only the render: the card's `assignedSpool` prop had no subtype field at all, and the six places the printer card fills it in (regular AMS, AMS-HT and external spool, each in both Spoolman and internal-inventory mode) never passed one, so the value could not reach the component. The field is required now rather than optional, which is what stops the next call site from quietly omitting it — that omission is the whole of this bug. Three more surfaces were rebuilding the name the same way and are fixed with it: the SpoolBuddy AMS slot panel in both inventory modes, and the write-tag confirmation. Every other place a spool is named — the assign dialogs, the inventory cards, the forecast rows, the label picker — already included the subtype, so these four were the outliers. This is the display-side half of #2902, which stopped the backend reducing a filled or foamed filament onto its base material; the card was doing the same thing to the same spools, one layer further out.
- **The bundled chamber-preheat table was unreadable to the code that reads it** — every lookup of the per-filament chamber map happens after the keys are upper-cased, but an install that had never opened the setting got the bundled table back exactly as declared, with its lowercase `default` row. The scheduler then looked for `DEFAULT`, found nothing, and used a hardcoded 0 for any filament without a row of its own. It reported the right number only because that bundled default is 0 — raising it would have silently changed nothing for everyone who had not customised the map. Both paths out of the parser now honour the one contract it documents.
- **The archives API never reported which plate was printed (#2796, contributed by @sgiffhorn)** — `archive_to_response()` builds the archives response field by field and had no line for `plate_id`, so `GET /archives/`, the detail endpoint, search, PATCH and the project archive list all answered `plate_id: null` — for archives whose column was populated as well. `ArchiveResponse.plate_id` defaults to `None`, so Pydantic filled the null in without complaint and nothing ever raised. The column has been written since #2603 and is backfilled from the queue on startup, so the plate was recorded all along and simply could not be read back out; on the reporting instance 181 of 257 rows carry one. Archive cards now name the plate again — but only when it is not the first one. The queue records a plate for single-plate files too, because the print dialog auto-selects the only plate there is, so labelling every archive that has a `plate_id` would have put "Plate 1" on most cards in the grid and taken room from the truncated print name. A multi-plate archive printed from its first plate still identifies itself through the plate carousel, which has always gated on whether the source 3MF holds more than one plate.
- **The first AMS sync after enabling Spoolman from Settings failed on every slot (#2903, diagnosed by @ojimpo)** — Spoolman rejects a spool whose `extra` dict carries a key it has not been told about, answering HTTP 400 `Unknown extra field tag.`. Bambuddy stores the tray UUID in `extra.tag`, so that key has to be registered before the first spool is created — and registration only ever ran from three hand-maintained lists that fire when the integration is *set up*: the Connect button, application startup, and two inline blocks in the inventory routes. Enabling Spoolman from the Settings page reaches none of them, so "Sync AMS Data" reported `Synced 0 spools with 3 errors` with vendor and filament creation succeeding and only spool creation rejected. Restarting Bambuddy cleared it, which made the failure look like a connectivity problem. The Connect button would also have fixed it, but it is not on screen by then: saving the settings initialises the Spoolman client as a side effect of syncing locations, the status endpoint reads any live client as "connected", and the UI shows the Connect button only while disconnected — so the one registration path reachable from the interface hides itself exactly when it is needed. Registration now travels with the write instead of the feature: every spool write that carries an `extra` dict registers the keys it is about to send, once per client, before it sends them. That covers all five places Bambuddy writes a tag — AMS sync, linking and unlinking a tag from a spool, and both inventory edit paths — and it closes the class rather than the instance, since a write that carries a key is now a write that registers it. `bambu_color_name` is the cautionary case: it never made it into the Connect or startup lists at all, and worked only because two call sites remembered to register it by hand. Registration stays best-effort — if it fails, the write is still attempted and reports exactly what it reported before, and the failure is not cached, so a Spoolman that was merely restarting is retried on the next write.
- **The Spoolman connection status described Bambuddy's memory rather than Spoolman (#2903)** — "Connected" meant "some earlier request in this process left a client object behind", and around twenty code paths build one lazily, so the answer turned on which page had been opened rather than on anything about Spoolman. A switched-off integration could still report Connected from a leftover client; changing the URL kept reporting on the previous host; and saving the Settings page built a client as a side effect of syncing locations, which is how enabling Spoolman came to report Connected before anything had been set up. The status now asks the Spoolman that is configured right now, so the answer is the same whatever you opened first. The Disconnect button is gone with it: Spoolman is a stateless HTTP API with no session to close, so the button only dropped the client object that the next request rebuilt moments later, after which the status flipped back on its own — it looked like it had worked, then quietly undid itself. Turning the integration off is the enable toggle's job, and Connect remains as what it actually is, a way to re-check a Spoolman that is not answering.
- **An AMS slot assigned a PLA+ spool became unusable for PLA (#2902, reported by @doncaruana)** — Assigning a spool wrote its material straight into the slot's `tray_type`, and a slot that says "PLA+" satisfies nothing that asks for PLA: not OrcaSlicer, not Bambu Studio, and not Bambuddy's own dispatch matcher, which compares the printer's reported type to the one the 3MF declares. The same string then missed the filament-id lookup, so the slot went out with no `tray_info_idx` at all — the half-configured state a printer reverts from — and took the generic 200/240°C nozzle range instead of PLA's. PLA+ is not a special case: Bambuddy's own colour catalogue supplies the material dropdown, and about forty of its values are vendor product lines rather than filament types — HTPLA, PolyTerra PLA, PLA Matte, ASA Extrafill, Flexfill TPU 98A. All four routes that configure a slot now reduce the material to a type the printer knows before sending it, and the product name moves to `tray_sub_brands`, which is where Bambu Lab itself puts it — their catalogue has a preset named "eSUN PLA+" whose type is PLA. A material that cannot be placed is sent exactly as before rather than guessed at, so this can only repair a slot, never break a working one; and a material that already had a preset of its own keeps it, so "PETG HF" is not quietly downgraded to plain PETG. One thing that starts working as a result: a slot holding a calibrated preset is now reused when a same-material spool is assigned to it, which for these spools could never happen before. A filled or foamed variant is a type in its own right and is left alone: PLA-AERO, PLA-GF, ASA-GF and PPS-GF were being reduced onto their base material, so a plain PLA plate could have been dispatched onto foaming filament — every type the Profiles editor offers now reaches the slot intact, whether it is written "PLA-AERO" or "PLA Aero". And when a spool points at a slicer preset, the slot takes that preset's own filament type rather than one read out of the material column, since a preset is chosen from a list the slicer defines; the material is still what a spool without a preset is read from. Two lookups keyed by material had to learn the same distinction: a variant with no nozzle range of its own now takes its base material's rather than the 200/240 catch-all, so an ASA-GF spool is no longer sent out at ASA's minimum minus thirty degrees, and the preheat chamber target does the same — ASA-CF and ABS-GF reach a warm chamber for the first time, while PETG-CF and PA-CF keep the hotter rows they are listed with.
- **A failed upload told you to check the SD card, whatever had actually gone wrong (#2899, reported by @grolmus)** — Every dispatch upload that failed carried the same sentence: "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)." The reporter got it after a TLS handshake failure and restarted the printer on the strength of it. That could not have helped — the handshake never reached the printer's filesystem, and the cool-off that made the next dispatch fail identically lives in Bambuddy's own memory, where power-cycling a printer does not reach. #2780 had already taken operator advice out of this failure's log line, for exactly the reason that the advice was known not to work; it survived in the string people actually read. The information to say something true was never missing. The FTP client separates five connect failures and three upload reply codes, each with its own log line — 553 even gets a spelled-out list of storage causes — and then handed the caller a bare true-or-false, so the dispatch had nothing to go on and guessed storage for all of them. The reason now travels with the result, and the message is chosen from it: a 553 or 552 keeps the card advice, which is the case it was written for, and quotes the printer's reply code so a queue entry and a support bundle can be lined up. A handshake failure says the file service answered without TLS and that the card is not involved. A refused connection points at the access code, a timeout at the network, and anything the client could not classify says so and points at the log rather than picking a plausible cause — a wrong instruction costs more than a vague one, because it sends someone to work on hardware that is fine. No message prescribes a power cycle, which is the restraint #2780 settled on. The failure notification carries the same sentence the queue shows, rather than its own fixed "Failed to upload file to printer", so a push and the screen can no longer disagree.
- **One handshake failure took out three queued jobs and every retry they had (#2898, reported by @grolmus)** — A print dispatch that met a TLS handshake failure spent its whole retry budget without opening a single socket. The cool-off that a failed handshake arms (#2780) lives inside `connect()`, so it applied to everything — including the dispatch, whose four attempts two seconds apart were all answered from the gate rather than the network. The reporter's farm logs the shape exactly: the delete that clears the way for the upload took the SSL error at 11:10:04.956, the upload's first attempt started 8ms later, and attempts two through four took one to two milliseconds each. Because the cool-off runs for five minutes, the next two jobs queued for that printer failed the same way inside the same window, and on that farm the failure is transient — a manual connect a second later completes cleanly — so the retry the gate suppressed is the one that would have worked. The gate was serving two callers that want opposite things from it. The background sweeps that fetch a 3MF, a cover or a timelapse after a print walk about a hundred and ten candidate paths against one wedged printer with nobody waiting, and backing off for minutes is right for them; they keep today's behaviour untouched. A dispatch is one delete plus at most four upload attempts with someone watching a progress bar, so it now ignores the cool-off, as does a firmware upload, for the same reason. Callers that do respect the cool-off no longer sleep out a retry loop against it either: the loop stops at the attempt that armed the gate and says so, instead of spending three more attempts and six seconds on connections that cannot happen. What made this a log dive rather than a glance is fixed with it: the cool-off skip was the one connect failure that reported without naming its cause, and did so at debug level, so four identical reason-free warnings were all the operator saw. It now says at warning level that nothing was sent and how long the printer has left — once per cool-off rather than once per attempt, so that raising it does not recreate the log flood #2780 set out to stop.
- **Archived projects crowded out the live ones in every project picker (#2888, reported by @e77)** — The reporter files each job under its own project and archives it when the job is done, so five active projects sat behind thirty-odd finished ones in the Project dropdown of the Edit Archive dialog — an unscrolled list of everything ever created, with no way to tell which entries were still live. That dropdown, the one on the pending-uploads panel, the bulk "Add to Project" dialog and the File Manager's folder link now leave archived projects out. Completed projects stay: a project marked completed says the work is done, not that it should be hidden, and filing a reprint under one is ordinary. The Archives right-click submenu had gone the other way and offered active projects only, so a completed project was reachable from the Edit dialog and not from the menu beside it; all five surfaces now apply the same rule. Whatever an archive is already filed under stays on its own list whatever its status — a `<select>` 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 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.
- **H2D archives lost their 3MF, thumbnail and filament data after the last update (#2856, reported by @aishlai)** — When a print starts, the printer says where it put the sliced file, and Bambuddy took `brtc://emmc/<name>` — internal storage — as proof there was nothing to fetch, so it archived the print by name alone. On an H2D with a card in the slot that is not true: the same file sits under `/cache` and downloads without complaint, as it had for that reporter's every print until the change landed. Bambuddy now checks instead of assuming. The printer names the exact file, so confirming it takes one connection across five paths rather than the ~110-connection search that made skipping worth doing — and when the file really is out of reach, as it is on an H2C or P2S with no copy on the card, the archive falls back exactly as before and still says why. The connection diagnostic asks the same question before warning that a print is out of reach. Covered by backend tests.
- **A printer with more than one smart plug never recorded energy or energy cost (#2859, reported by @sn8key)** — Linking a second plug to a printer — a dry box, a filter fan, a chamber light you want to switch from the printer card — stopped energy tracking on that printer completely, from the moment the second plug was linked. Per-print energy is the change in one plug's meter between the start and the end of a print, and both readings asked for "the plug on this printer" in a way that accepts one answer and fails on two. The failure was indistinguishable from having no plug at all: the print-end log said no starting reading had been taken, which is also what it says for a printer with nothing linked to it. Bambuddy now picks the printer's own plug — the one marked as supplying its power, and of those the one that actually reports a meter, so accessories drop out on their own — and names the plugs it tried when none of them measures anything. Linking several plugs to a printer is supported and always was; only energy assumed otherwise. Past prints cannot be recovered, since the starting reading was never taken. Covered by backend tests.
- **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.
- **Reprints of a file already on the printer archived without their thumbnail or filament data (#2780 regression)** — Printing a file that is already on the printer — from the printer's own screen, from Handy, or after sending it to storage from a slicer and pressing print — reports the file by its path rather than by how it got there. Bambuddy read anything that was not a fresh upload as "the printer kept this on internal storage", stopped looking, and archived the print with only its name and timing. The file was on the card the whole time: on the machine this was measured on, `/media/usb0/foobar.gcode.3mf` was listable and downloadable over FTP at the moment Bambuddy decided it was unreachable. This affected every model, not only the H2 series and P2S the original change was about, and it arrived with that change on 2026-08-14 — before it, those prints archived normally. Bambuddy now reads the path: the printer's own internal model cache is still skipped, since nothing there is reachable, and everything else is looked for as it always was. Covered by backend tests.
- **Prints sent from a slicer were sometimes logged as though Bambuddy had sent them (#2843 follow-up)** — Bambuddy records the dispatch behind every print so a support bundle shows where the sliced file went, and it told its own dispatches apart from a slicer's by a sequence number it believed was unique to it. It is not: that number is the slicer convention Bambuddy adopted, and measured on the wire OrcaSlicer counts from it while Bambu Studio counts from the same base a few higher. Whichever dispatch happened to land on the shared value was filed as Bambuddy's own and never recorded — after a slicer restart, that is the first print you send. Bambuddy now recognises its own dispatch by the job it actually sent. Nothing about printing or archiving changed; the entry was diagnostic, but it is the entry that tells you whether a printer stores your files somewhere Bambuddy can read them. Covered by backend tests.
- **A print with no 3MF could take its filament figures from an unrelated model (#2843, reported by @gyrene2083)** — Bambu Studio sends a sliced file to the printer's internal storage on H2-series and P2S, which Bambuddy cannot read, so those prints archive without a 3MF ([BambuStudio#10481](https://github.com/bambulab/BambuStudio/issues/10481) tracks that default upstream). Bambuddy then looks for the same model in your Library or among earlier prints, which is how a reprint still gets its filament accounted for. The name it searched on was the wrong one. A running print reports the file it is executing — always `Metadata/plate_1.gcode` — and with no 3MF to correct it, that path became the archive's name and `plate_1` became the search term. Every Bambu print has a plate 1, so the search matched on nothing meaningful and took whatever came back: on the maintainer's H2D a 1.6 g Cube was costed from a 207 g four-colour ABS print whose file happened to be named `lid_plate_1.3mf`. The match now uses the model name the printer reports alongside the plate path, a plate name on its own is refused rather than searched for, and a name must match a whole filename instead of merely appearing inside one. A print that cannot be identified is left untracked, which is the honest answer — the previous behaviour was to charge your spools for a model you did not print. Covered by backend tests, including the exact collision measured on the H2D.
- **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.
- **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.
- **PostgreSQL installs on a non-UTC timezone showed AMS History and Archive timestamps hours in the future (#2855, reported by @Tolga-Unal)** — On UTC+3 every AMS humidity reading and every archive was stamped three hours ahead of when it happened. Bambuddy stores timestamps without an offset and treats them as UTC everywhere, and the Python side has done so since #504 — but around a hundred and fifty timestamps are not written by Bambuddy at all. They are database defaults, filled in by the database, and PostgreSQL fills them from a clock whose timezone is the server's own. A Postgres container started with `TZ=Europe/Istanbul` bakes that zone in when the cluster is created, so those columns received local wall-clock while everything reading them assumed UTC, and the display added the offset a second time. Bambuddy's connections now pin their session to UTC, so what the database writes matches what the rest of the product means, whatever the server is set to. SQLite was never affected — its clock is UTC by definition, which is why this hid for as long as it did, and the fix makes Postgres agree with SQLite rather than inventing a third convention. **Timestamps already recorded are not rewritten**: which of them were written by the database and which by Bambuddy cannot be told apart after the fact, and an install that started on SQLite holds both kinds. Everything from the upgrade forward is correct; older rows keep the times they were given. One related mismatch went with it — the support package's "oldest pending queue item" age subtracted a local clock from a UTC column, reporting a job queued five minutes ago as three hours old east of Greenwich and a negative age west of it. Covered by backend tests, including one that reproduces the reporter's three-hour shift.
- **K-profiles follow an AMS when it moves between Filament Track Switch inlets** — K-profiles are calibrated per nozzle, and the printer numbers its calibration table per nozzle too, so entry 16 exists on both hotends and means a different profile on each. An AMS tray, however, holds exactly one index. Move an AMS to the switch's other inlet and every configured slot in it silently keeps pointing at the old hotend's table: on the maintainer's H2C a black PLA calibrated 0.018 on the left and 0.020 on the right stayed on the left profile after the move, and a manual RFID re-read only re-asserted the same wrong one. Bambuddy already stores both profiles for a spool, so the move now re-selects the counterpart for the nozzle that AMS actually feeds. Only the calibration binding changes and only for slots whose spool already has a profile for the new nozzle — configuring a slot is a deliberate preparation step, so a slot Bambuddy knows nothing about, or a spool calibrated on one hotend only, is left exactly as you set it. Nothing is re-applied on the first sighting of a binding either, or every reconnect would overwrite a choice made by hand.
- **Configure Slot picks the K-profile for the slot's own nozzle** — The profile dropdown identified an entry by name and K value alone, with nothing naming the hotend, so a filament calibrated on both appeared twice with no way to tell them apart, and two that happened to share a K value collapsed into whichever the printer listed first. The tie-break meant to prefer the slot's own nozzle was gated on a value that is never set on a machine with a Filament Track Switch, where no AMS reports a nozzle at all — so the pick was arbitrary, and the fallback for an unrecognised binding was simply the first profile in the list. Options now carry the hotend, matches are scoped to the nozzle the slot actually feeds (the other hotend's profiles remain available under **Other K profiles**), and the slot's active index is resolved against its own nozzle rather than followed into the wrong table. The K value shown per slot on the printer card was affected by the same confusion and is now resolved the same way. Covered by backend and frontend tests.
- **Auto K-profile calibration no longer leaves an archive behind** — When flow dynamics calibration is on, the printer lays down a pressure-advance line before the print itself. It announces that over MQTT through the same print-start event a real print uses, so Bambuddy archived it: a row named `auto_pa_line_calib_mode`, marked as having no 3MF, in among your actual prints, plus a "Print started" and a "Print completed" notification for each one. Bambuddy has always skipped the printer's other internal jobs, but only by spotting the `/usr/` path they carry — and this one arrives as a bare subtask name with no path at all, so it went straight past. It is now recognised by name, from either field the printer might report it in, and matched exactly so that a file you have deliberately named after the calibration is still your file. The completion is quiet too, which matters more than the noise: with no archive to close, it would have fallen into the path that attributes an unmatched completion to any job the printer finished in the last five minutes — and a calibration that runs alongside a real print would have told that print's owner it was done, early. Skipping the run early also saves the pointless FTP sweep for a 3MF that cannot exist, roughly a hundred connections to a printer that is mid-calibration. Covered by backend tests.
- **A printer being busy or offline no longer blocks printing from its card (#2849)** — Dragging a sliced file onto a printer card refused the drop unless the printer was connected and neither printing nor paused, showing a red "Printer busy" and discarding the file; the card's **Print** button disappeared under the same condition. The workaround was to upload to the File Manager and queue it from there by hand, which is the same thing with more steps. It was never a real restriction: every print Bambuddy sends becomes a queue item, and dropping onto an idle printer only looks instant because the scheduler dispatches it right away. Both routes now work whatever the printer is doing, and a busy one simply queues the job. Offline printers are included, so you can queue work for a machine that is powered down and have it dispatch when it reconnects. The overlay says which you are getting — "Drop to print" when the job would start immediately, "Drop to queue" when it would wait, covering a print in progress, a paused job, an AMS mid-drying and a plate not yet cleared. That wording comes from the same check the Print Modal uses for its own "will start later" notice, so the card and the modal cannot disagree. The drop is also now gated on the permissions the flow actually uses, **Library Upload** and **Queue Create**, matching the Print button beside it; it previously checked Printer Control, which meant someone holding that but neither of the others got the file uploaded and then rejected by the queue, leaving it stranded in the library. Translated in all locales; wiki updated. Covered by frontend tests.
- **Drying an AMS no longer sends an hourly "temperature high" alert for the whole cycle (#1802)** — The AMS temperature alert compares against the same threshold that colours the printer card, which defaults to 35 C, while drying deliberately runs at 45 C for PLA, 65 C for PETG and up to 85 C on an AMS-HT. The alert repeats once an hour for as long as the condition holds, so a twelve-hour dry sent twelve notifications about a temperature you asked for, and then kept sending them while the unit cooled back down. The alert is now held back for the length of a cycle and through the cool-down that follows it, using the drying state the firmware already reports rather than anything you have to configure. Suppression lifts as soon as the unit reads back at or below your threshold, so a 65 C cycle in a cold basement and a 45 C one in a warm room each get exactly the cool-down they need instead of a fixed guess. Two things are deliberately left alone: the humidity alert, which is the one you want during drying because the number falling is the point, and a unit reporting `HeatOutOfControl`, where an AMS that has lost thermal control is precisely when the alert should still reach you. Because a cycle plus its cool-down can outlast a restart, the suppression is stored rather than held in memory. No new setting — the alert simply stops firing for heat you asked for. Wiki updated. Covered by backend tests.
- **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 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.
### Security
- **Bumped the Tiptap editor stack to 3.31.1 for a prototype-manipulation advisory in `@tiptap/core` (GHSA-cp6q-959q-f8rh)** — `mergeAttributes()` copies keys straight out of `Object.entries()` with ordinary bracket assignment, so an own `__proto__` key coming from JSON invokes the legacy prototype setter instead of writing a property: the returned object carries an attacker-controlled prototype while `Object.keys()` and own-property checks show nothing. That matters because ProseMirror's `DOMSerializer.renderSpec()` enumerates attribute objects with `for...in`, which walks inherited keys — an inherited `src` and `onerror` become real attributes on a rendered `<img>` and the handler runs in the app's origin. Medium severity, CVSS 4.0 6.4; the fix landed in 3.30.4 and the whole stack moves 3.19.0 → 3.31.1. **No running Bambuddy install was exposed.** The advisory needs either an untrusted object reaching `mergeAttributes()` or a custom/dynamic extension that preserves the attribute object, and Bambuddy has neither: nothing under `frontend/src/` calls `mergeAttributes` or defines an extension, and the one editor (`RichTextEditor`) builds a fixed schema from StarterKit plus six stock extensions whose `HTMLAttributes` are static literals. Content also crosses the boundary as an HTML **string**, never as JSON, so no own `__proto__` key can reach an attributes object in the first place — ProseMirror's DOM parser only fills in the attributes the schema declares — and every read-only render of that content is sanitized (`DOMPurify.sanitize` for project notes, `sanitizeHtml` in the project-page modal). This is a lockfile-only change: `frontend/package.json` already declared `^3.11.1`, so the patched line was inside the existing range and only the stale lock held it back; no `overrides` entry was needed. A side effect worth recording is that `@tiptap/pm` has narrowed what it pulls in, so `prosemirror-markdown`, `prosemirror-menu`, `prosemirror-collab`, `prosemirror-schema-basic`, `prosemirror-trailing-node`, `markdown-it` and `linkify-it` leave the tree entirely (16 packages) — which retires the reachability argument recorded for the `linkify-it` bump in 1.2.5, since that package is simply no longer there. Verified with eslint, the production build and its Safari 16 baseline check, and the full frontend suite (3514 tests across 256 files); `npm audit --omit=dev` reports zero vulnerabilities.
- **Bumped the build and lint toolchain for three development-dependency advisories in `browserslist` and `@humanfs/node` (GHSA-73wf-gq98-2v4g, GHSA-c83g-rgw3-j3cx, GHSA-p498-v437-472g)** — `browserslist` moves 4.28.1 → 4.28.8 for two high-severity issues: `normalizeStats()` walks an untrusted `browserslist-stats.json` with an unguarded `for...in` and uses the keys for plain bracket access and assignment, so a `__proto__` or `constructor` key either crashes the build or writes to the prototype (CVE-2026-73088), and the query-result cache has no eviction at all, so a long-lived process fed distinct queries grows without bound (CVE-2026-73089). `@humanfs/node` moves 0.16.7 → 0.16.8 for a medium-severity path-traversal issue where `copyAll()` ignores symlink state and `fs.copyFile()` dereferences the link, copying data from outside the source tree. **No running Bambuddy install was exposed, and neither issue was reachable even at build time.** Both packages are development-only — they are absent from the shipped image, and `npm audit --omit=dev`, which is what CI gates on, reported zero findings before and after. `browserslist` is never called by our own code; it arrives under `autoprefixer` and `@babel/helper-compilation-targets`, there is no `browserslist-stats.json` anywhere in the repository or up the directory tree, no `browserslist` key in `package.json` and no `.browserslistrc`, and nothing passes `--stats` or `opts.stats`, so the untrusted input the first advisory needs has no way in; the unbounded cache needs a long-lived process taking attacker-chosen queries, where `vite build` is one-shot with a fixed query. `@humanfs/node` arrives under `eslint`, which calls only `isDirectory` and `walk` and never copies anything, so the copy path the advisory describes is never entered. Lockfile-only — every existing range already admitted the patched versions, so `frontend/package.json` is untouched. The bump carries `caniuse-lite` 1.0.30001769 → 1.0.30001810, `baseline-browser-mapping` 2.9.19 → 2.11.20, `electron-to-chromium` 1.5.286 → 1.5.420, `node-releases` 2.0.27 → 2.0.54 and `update-browserslist-db` 1.2.3 → 1.3.2, all of which feed autoprefixer's target data — the rebuilt bundle is byte-identical, same content hashes, so `static/` does not change. Verified with a clean build against the Safari 16.0 baseline check, eslint, 3514 frontend tests across 256 files, i18n parity in all 13 locales, and `npm audit` reporting zero findings with and without dev dependencies.
- **Bumped `fflate` to 0.8.3 for a denial-of-service advisory reachable through three's compressed-format loaders (GHSA-px8p-9vwx-vf98, #3034)** — `unzipSync()` treats a central-directory entry whose compressed size is the Zip64 sentinel `0xFFFFFFFF` as a Zip64 entry and scans its extra field for the header id `0x0001`, with nothing telling the scan where that field ends: `z64e()` then reads past the end of the buffer, the `undefined` that comes back coerces to 0, and the loop condition can never go false, so the tab spins at 100% CPU until it is killed (CVE-2026-45820, medium, CWE-400). **No running Bambuddy install was exposed.** Unlike the other two entries here this one is classified runtime rather than development scope, so it is worth saying exactly why it cannot fire: `fflate` reaches the tree only as a dependency of `@types/three`, which is a types-only package whose imports TypeScript erases at compile time, so it never becomes a runtime import at all. The ten three.js addons that genuinely call it — `3MFLoader`, `FBXLoader`, `KMZLoader`, `AMFLoader`, `EXRLoader`, `USDLoader`, `VTKLoader`, `NRRDLoader`, `USDZExporter` and `EXRExporter` — are imported nowhere in the frontend or the backend; the four this project does use, `OrbitControls`, `BufferGeometryUtils`, `STLLoader` and `RoomEnvironment`, reference none of it. `3MFLoader` is the one worth checking twice given what Bambuddy spends its time reading, and it is genuinely absent: 3MF files are parsed on the backend, not in the model viewer. The shipped bundle confirms it, carrying no fflate signature whatsoever — `unzipSync`, `z64e`, `invalid zip data`, `no stream handler` and `extra field too long` are all absent, and the one inflate error string that does appear belongs to pako, which the surrounding `e.msg=` / `n.mode=30` zlib-port idiom identifies unambiguously. Lockfile-only: three lines, no `frontend/package.json` change, nothing added or removed, and no transitive churn. The reason a package that never ships shows up in runtime scope at all is that `@types/three` sits in `dependencies` rather than `devDependencies`, which is left alone here rather than moved in a security bump.
- **Bumped `fflate` to 0.8.3 for a denial-of-service advisory reachable through three's compressed-format loaders (GHSA-px8p-9vwx-vf98, #3034)** — `unzipSync()`g `0x0001`: `z64e()` then reads past the end of the buffer, the `undefined` that comes back coerces to 0, and the loop condition can never go false, so the tab spins at 100% CPU until it is killed (CVE-2026-45820, medium, CWE-400). **No running Bambuddy install was exposed.** Unlike the other two entries here this one is classified runtime rather than development scope, so it is worth saying exactly why it cannot fire: `fflate` reaches the tree only as a dependency of `@types/three`, which is a types-only package whose imports TypeScript erases at compile time, so it never becomes a runtime import at all. The ten three.js addons that genuinely call it — `3MFLoader`, `FBXLoader`, `KMZLoader`, `AMFLoader`, `EXRLoader`, `USDLoader`, `VTKLoader`, `NRRDLoader`, `USDZExporter` and `EXRExporter` — are imported nowhere in the frontend or the backend; the four this project does use, `OrbitControls`, `BufferGeometryUtils`, `STLLoader` and `RoomEnvironment`, referencenone of it. `3MFLoader` is the one worth checking twice given what Bambuddy spends its time reading, and it is genuinely absent: 3MF files are parsed on the backend, not in the model viewer. The shipped bundle confirms it, carrying no fflate signature whatsoever — `unzipSync`, `z64e`, `invalid zip data`, `no stream handler` and `extra field too long` are all absent, and the one inflate error string that does appear belongs to pako, which the surrounding `e.msg=` / `n.mode=30` zlib-port idiom identifies unambiguously. Lockfile-only: three lines, no `frontend/package.json` change, nothing added or removed, and no transitive churn. The reasona package that never ships shows up in runtime scope at all is that `@types/three` sits in `dependencies` rather than `devDependencies`, which is left alone hererather than moved in a security bump.
- **Bumped Vitest to 4.1.11 for a path-traversal advisory in `@vitest/mocker` (GHSA-82fw-gwwq-j7x9)** — the mocker registers a redirect mock's target path without checking it against Vite's file-serving allowlist, and the plugin's `load` hook then hands back `readFile(mock.redirect)` as the module source. Registration derives that path as `join(server.config.root, new URL(event.redirect).pathname)`, which does not confine anything: a non-special scheme leaves `..` segments in `pathname`, so the join resolves outside the project root, and even a path that stays inside it is read without the `server.fs.deny` check the dev server would otherwise apply to an in-root `.env`. Medium severity, CVSS 3.1 5.9, CWE-22; fixed in 4.1.11 and 5.0.0, with 2.1.x and 3.x unmaintained. **No running Bambuddy install was exposed, and the issue is not reachable in this project's test runs either.** The unauthenticated variant is specifically the public `mockerPlugin` and standalone `interceptorPlugin` exports, which attach the handler to Vite's HMR WebSocket — a socket with no token, Origin or same-origin check — and those exports exist for third-party dev servers embedding the mocker; nothing under `frontend/src/` imports either one. Vitest's own browser mode registers mocks over a token-authenticated RPC instead, and it is not installed here at all: `@vitest/browser` appears in the lockfile only as an unmet optional peer, and `vitest.config.ts` runs a plain jsdom environment, so no dev server is listening during a test run in the first place. Both packages are development-only — absent from the shipped image, and `npm audit --omit=dev`, which is what CI gates on, reported zero findings before and after. Fifteen lockfile entries move, every one of them dev-scoped, with nothing added or removed: the eight `@vitest/*` packages and `vitest` itself go 4.1.8 → 4.1.11, carrying `es-module-lexer` 2.1.0 → 2.3.2, `expect-type` 1.3.0 → 1.4.0, `obug` 2.1.1 → 2.2.1, `std-env` 4.1.0 → 4.2.0, `tinyexec` 1.2.4 → 1.3.1 and `tinyrainbow` 3.1.0 → 3.1.1. Unlike the other entries here this one does touch `frontend/package.json`: the existing `^4.1.8` range already admitted the patched version, so the lock alone would have pinned it, but the declared floor is raised to `^4.1.11` so that a regenerated lockfile cannot resolve back below the fix. Nothing in `src/` changed, so the rebuilt bundle is byte-identical and `static/` does not move. Verified with the full frontend suite running on 4.1.11 (3562 tests across 259 files), eslint, the production build and its Safari 16.0 baseline check, i18n parity in all 13 locales, and `npm audit` reporting zero vulnerabilities with and without dev dependencies.
## [1.2.5.5] - 2026-08-30
### Changed
- **Updating now repairs a service file that was written before the `--loop asyncio` pin existed (#3001)** — `install.sh` has pinned the loop since 2026-07-05 (#1896), but nothing has ever rewritten an *existing* service file: `install/update.sh` does a `git reset --hard`, a pip install, a frontend build and a restart, and never touches the unit. uvloop has been in every native venv since `uvicorn[standard]` entered `requirements.txt` on 2025-11-28, and uvicorn's `--loop auto` prefers it, so every native install created in that seven-month window has been running on uvloop ever since and no amount of updating has changed that. It cost them every RTSP camera on 1.2.5.4, and before that it left them exposed to a Virtual Printer FTP upload being silently truncated into a corrupt `.gcode.3mf` — which is why #1896 shipped a ZIP-validation backstop alongside the pin, since the pin could never reach the installs that already existed. Both update scripts now add the missing flag themselves: `update.sh` to the systemd unit, `update_macos.sh` to the launchd plist, in each case while the service is stopped so the repair takes effect on the same restart. Only the one flag is ever inserted — a hand-edited port, extra hardening, `ExecStartPre` lines and everything else stay byte-identical, and the file is copied to a timestamped backup first. Anything that is not a single-line unit invoking uvicorn directly is described rather than edited: a wrapper script, an `ExecStart` continued across lines, several `ExecStart` lines, a unit that is not writable, or a service carrying systemd drop-ins, since a drop-in may be what defines `ExecStart` and editing the fragment would then change nothing while reporting success. A loop pinned deliberately is also left alone — someone who wrote `--loop uvloop` on purpose gets no argument, only the startup warning. The check reads the *effective* `ExecStart` from systemd rather than the file, so a drop-in that already pins the loop counts and the repair is idempotent.
- **Bambuddy now says so at startup when it is running on uvloop (#3001)** — every unit file this project ships pins `--loop asyncio`, added for #1896 because uvloop's SSL layer can drop buffered data and truncate a Virtual Printer FTP upload into a corrupt `.gcode.3mf` that is acked `226` and forwarded to a printer. Two populations run a unit nobody here wrote and therefore have no such pin: the Proxmox VE Helper-Scripts LXC, which composes its own `ExecStart`, and native installs created before that fix landed on 2026-07-05, which never gained the flag because `install/update.sh` does not rewrite unit files. Neither had any way to know. #3001 only surfaced them because losing every camera at once is loud; a truncated upload is silent, and there is nothing to notice until a print fails from a file that was corrupt on arrival. Startup now logs one WARNING naming the loop, the risk and the exact flag to add. It is a warning and not a refusal: a server that answers requests beats a purist one that will not boot, and by the time any application code runs uvicorn has already chosen its loop. The check asks the running loop what it is rather than whether uvloop imports — uvloop is a hard dependency here, since `requirements.txt` pins `uvicorn[standard]`, so its presence says nothing about what is in use — and it matches on the module name so that asking the question never imports uvloop on a host that lacks it.
### Fixed
- **Every camera stopped working on 1.2.5.4 (#3001, reported by @Jieper001, confirmed by @JmanB52D and @hikingthunder)** — live view, snapshots, timelapse frames and the camera diagnostic all failed at once on every RTSP model — X1, H2 and P2 — with the in-app diagnostic reporting `capture_exception` at 0 ms while network reachability passed at 1 ms. That 0 ms is the whole story: the failure happened before a socket was opened. The RTSPS proxy added in 1.2.5.4 finished by hanging its set of in-flight connection handlers on the server object as an attribute. `asyncio.start_server` returns an `asyncio.base_events.Server`, which has a `__dict__` and accepts that; under uvloop it returns a `uvloop.loop.Server`, a Cython cdef class with no `__dict__`, which raises `AttributeError` outright. Every launch path this repo ships — the Dockerfile, `install/install.sh`, `deploy/bambuddy.service`, the Windows service and the SpoolBuddy installer — pins `--loop asyncio`, added for #1896, so none of them selects uvloop and none of them could hit this. What broke is the installs running a unit file we did not write. The Proxmox VE Helper-Scripts LXC composes its own `ExecStart` with no loop pinned, and `requirements.txt` pins `uvicorn[standard]`, which installs uvloop on Linux, so uvicorn's default `--loop auto` selects it — that is the reporter's install and the two that confirmed it. Native installs created before the #1896 pin landed on 2026-07-05 are in the same position for a different reason: `install/update.sh` never rewrites the unit file, so a service written before that date has never been given the flag by any update since. Those installs are also still exposed to #1896 itself, where a truncated Virtual Printer FTP upload corrupts a `.gcode.3mf` silently; the camera outage is simply the visible half. Hence a fix in the code rather than another flag in a unit file: the proxy now works on either loop instead of depending on the launch command to steer around it. The handler set now lives in a module-level registry keyed weakly by server, which both loops accept; keying it weakly rather than by `id(server)` means a proxy abandoned without a close takes its entry with it, instead of leaking one forever and eventually handing a new server a dead one's handlers once CPython recycles the address. A1 and P1 use the chamber-image protocol and return before the proxy is built, so they were never affected, and external RTSPS cameras caught the error and fell back to a direct connection, so they kept working without the TLS workaround. The reason the test suite could not see any of this is that `conftest` builds its event loop from the default policy, so every async test in the repo runs on the selector loop — the one loop where the assignment was legal. The regression is now pinned twice: once by a test that drives the real function on a real uvloop loop, and once by a test that gives it a `__slots__` server, so the contract holds even where uvloop is not installed. The two external-camera teardowns were also switched to `close_tls_proxy`, which #2968 introduced and left them out of, so they no longer leave handlers running past the server that owned them.
- **Bumped `js-yaml` to 5.4.2 for a merge-key denial-of-service advisory** — In versions up to 5.4.0, the `maxTotalMergeKeys` limit does not count empty mappings. A small YAML document that merges a long list of `{}` many times (`<<: *arr`) can keep the CPU busy for seconds per few hundred kilobytes without ever reaching the limit. It only applies when merge keys are enabled, which means the YAML 1.1 schema. The fix counts each merged mapping towards the budget. **No running Bambuddy install was exposed, and the issue was not reachable at build time either.** `js-yaml` is a development-only transitive dependency, pulled in by `eslint` through `@eslint/eslintrc`, and it is not in the shipped image. `@eslint/eslintrc` only parses legacy `.eslintrc.yaml`/`.eslintrc.yml` files, and this repository has none. Linting uses the flat `frontend/eslint.config.js`, so no YAML is loaded at all. The existing `overrides` pin in `frontend/package.json` moves from `^5.2.3` to `^5.4.1`, so a regenerated lockfile cannot resolve back below the fix. The lockfile changes in one entry only, with nothing added or removed, and the shipped bundle does not change. Verified with eslint, typecheck, the production build, and the full frontend suite (4022 tests across 297 files). `npm audit` reports no `js-yaml` findings.
- **Bumped `brace-expansion` to 5.0.12 for three denial-of-service advisories** — Two high-severity issues, fixed in 5.0.10 and 5.0.11, let deeply nested brace groups exhaust the native stack. Roughly 3,100 levels of `{{{...a,b...}}}`, about 6 KB of input, crashed the process: once through the recursive comma splitter and once through the expander itself. The medium-severity third, fixed in 5.0.12, is the bash `{a},b}` quirk. The parser handles it by rewriting the string and rescanning once per trailing `}`, so the cost grows quadratically, and 128 KB of input blocked the event loop for 27 seconds. 5.0.12 makes the comma splitter iterative and caps nesting depth and rewrite passes at 1,000 each. Input past either cap is kept as plain text rather than throwing, the same way the existing `max` and `maxLength` limits work. **No running Bambuddy install was exposed, and none of the issues was reachable at build time either.** `brace-expansion` is a development-only transitive dependency, pulled in by `eslint` through `minimatch`, and it is not in the shipped image. The only patterns it ever expands are the fixed globs in `frontend/eslint.config.js`. Before the bump, the output of 5.0.9 and 5.0.12 was compared on every glob in that file plus ranges, nested sets, escaped braces and the `{a},b}` quirk, and it was identical. The same comparison confirmed the fixes: 5,000 levels of nesting overflow the stack on 5.0.9 and expand cleanly on 5.0.12, and a 20,000-brace rewrite input drops from 3 seconds to 13 ms. The existing `overrides` pin in `frontend/package.json` moves from `^5.0.9` to `^5.0.12`, because `minimatch` still asks for only `^5.0.2`. The lockfile changes in one entry only, `balanced-match` is untouched, nothing is added or removed, and the shipped bundle does not change. Verified with eslint, typecheck, the production build and the full frontend suite (4022 tests across 297 files). `npm audit` reports zero vulnerabilities with and without dev dependencies.
## [1.2.5.4] - 2026-08-29