# Changelog All notable changes to Bambuddy will be documented in this file. ## [1.2.6b1] - Unreleased ### Added - **Nest projects under a master project and see the whole programme in one place (#1264, reporter @enjoylifenow)** — A project has always been a flat thing: a build with fifty parts and a build with two got the same single row, and the only way to keep a large job legible was to split it into separate projects that then knew nothing about each other. Projects can now be nested. The project dialog has a **Parent project** picker, so an assembly can sit under the build it belongs to, at whatever depth suits the work; the picker leaves out the project itself and anything already beneath it, because nesting a project inside its own branch is a loop rather than a hierarchy. A project that has sub-projects gains a second card reporting the whole tree at once — print jobs, parts, time, filament and total cost, with progress measured against every target in the tree added together. That card is deliberately separate from the project's own figures, which keep meaning exactly what they meant before: what this project printed, not what its sub-projects did. Each sub-project listed underneath now carries its own branch's totals rather than only a percentage, so the rows add up to the card above them instead of contradicting it. On the Projects page a sub-project is drawn inside its parent's group rather than as another card somewhere in the grid, because two cards that belong together cannot show it while they sit columns apart, however they are captioned; the group is ruled in the parent's own colour and nests as deep as the projects do. A sub-project whose parent is hidden by the status filter stays where it is and says which project it belongs to instead. Two things that only became reachable once the interface could reach them were fixed along the way: a project could be moved under its own sub-project, which the API refused only when a project was made its own direct parent, and a percentage shown against a sub-project was measured differently from the same percentage on the page it linked to. Deleting a project in the middle of a tree now lifts its sub-projects up to its own parent instead of cutting them loose at the top level. - **Restore selected categories from a Git backup commit (#2714, contributor @jmoore-skild, requested in #2656)** — Bambuddy has pushed backups to GitHub, GitLab, Gitea and Forgejo for a long time, and every one of those commits was a restore point that nothing could read back. Recovering from a bad settings change, a rebuilt instance or a lost database meant opening the repository by hand and copying JSON into the right places, if you knew which places those were. **Settings → Backup & Restore → Restore from Git** now picks any of the twenty most recent commits and pulls back the categories you tick — K-profiles, app settings, spool inventory and print history — without touching anything you did not select. The modal previews the commit before anything is written: it shows how many items each category holds and greys out the ones that commit does not contain, so a category you only enabled last week is visibly absent from older commits rather than silently restoring nothing. **Overwrite existing entries** decides what happens when something already exists locally — off, it fills in what is missing and leaves the rest alone; on, it makes the local row match the backup. The result panel reports what actually happened per category as restored, skipped and failed, and those three always add up to the number the preview showed you, so a count that does not match the preview is a bug rather than something to interpret. Restoring never resurrects a credential: the backup carries MQTT, LDAP, Home Assistant and Prometheus secrets so that a repository is a complete record, but the restore refuses every one of them, and refuses along with them any switch that would be left pointing at a service it can no longer authenticate to — an exposed Prometheus endpoint with no token is worse than one that stays off. The keys that decide who can reach the instance at all are refused outright for the same reason — the four authentication-policy switches, and the whole LDAP family alongside them, since those name *which directory server decides who you are* rather than how the instance behaves. Authentication is reconfigured through the auth UI, which has the guards that a JSON file does not. Print archives come back as history only, since a Git backup holds metadata and never the 3MF or thumbnail bytes, and each one is returned to its owner by username rather than by user id — an id means nothing on a rebuilt instance, where it would hand one person's print history to whoever now holds that number. An archive whose owner this instance does not have lands unowned with a note saying so, rather than being attributed to a stranger; one that already exists locally keeps the owner it already has, because an owner the backup cannot name is not an instruction to take one away. K-profiles are the one category that leaves the database: they are sent to the printer over MQTT, which means the printer must be online, and writing a slot is always an overwrite there regardless of the toggle — the modal says so before you click rather than in the summary afterwards. Cloud profiles are backed up but deliberately not restorable, as writing them means writing to a Bambu or Orca account rather than to this instance. **Restoring is permissioned per category**: `github:restore` opens the dialog, and each category additionally requires the permission that owns its rows — `settings:update`, `inventory:update`, `archives:update_all` and `kprofiles:update` — so a role cannot write through a restore what it cannot write through the page that owns it. Administrators hold all of them already; a custom role built around the Backup permissions alone can open the dialog and preview a commit, but needs the owning permission for each category you want it to be able to write. Each category is committed as it completes rather than at the end, so a large restore does not hold the database against the rest of Bambuddy for the length of the run; the trade is that a failure part-way through leaves the categories that already finished in place, which the result panel reports rather than claiming nothing was restored. Translated in all locales; wiki updated. Covered by backend and frontend tests. - **Home Assistant sensors on the printer card, with an optional print interlock (#1148, reporter @bsaunder; #448, reporter @baudneo)** — Bambuddy could already switch a Home Assistant entity as a smart plug, but it had no way to *read* one. A printer in a home-built enclosure with a door contact, or an A1 with an aftermarket chamber thermometer, had all that data in Home Assistant and none of it in Bambuddy — the reporter's actual problem being that he could not tell whether he had left the enclosure open before starting a print from his phone. **Settings → Smart Plugs → Home Assistant Sensors** now binds any `binary_sensor` — a door, window, smoke or moisture contact — or any `sensor` that carries a reading to a printer, and its state appears on that printer's card. The wording follows Home Assistant's own device class, so a door reads Open or Closed rather than On or Off, and a thermometer reads `41.2 °C`; entities with no device class fall back to on/off, exactly as Home Assistant shows them. A sensor can be given an alert condition — on, off, above a value, below a value — which highlights it on the card and unlocks two things it would otherwise be pointless to offer. **Notify on alert** sends a notification the moment the sensor enters that state, once on the way in rather than on every poll, and not again when a flaky contact drops off the network and comes back still alerting. **Hold prints while alerting** is the part that answers the original question without anyone having to look: queued jobs for that printer wait, with a reason on the Queue page you can read at a glance ("Waiting on Enclosure Door"), and start by themselves as soon as the door shuts. Nothing is ever cancelled, and a job queued as "Any X1C" simply goes to a sibling whose sensors are clear instead of waiting behind the one that is held. The interlock is deliberately one-directional: it holds only on a sensor that was read successfully and *is* alerting, so a Home Assistant that is unreachable holds nothing and the queue keeps running as though no interlock were configured. Sensors are their own thing rather than a smart plug with a wider entity filter — a plug carries auto-on, schedules, power alerts and "controls printer power", and the printer card's power button would have happily tried to switch a door contact. One backend poller reads every bound entity every 15 seconds and the cards serve that cached reading, so the cost to Home Assistant does not grow with the number of printers on screen or browser tabs open. Off by default in every respect: a newly bound sensor is display-only until you give it an alert condition, and both the notification and the interlock are separate opt-ins on top of that. - **Open a File Manager model in your desktop slicer, and pick which one from the 3D preview (#2725, contributor @pascalheidmann)** — The **Slice** action on a file card only existed when the optional slicer sidecar was running. Turn the sidecar off — which is the default, and how most installs run — and the File Manager offered no way to get a model into a slicer at all, even though the Archives page has handed files to a locally-installed Bambu Studio or OrcaSlicer over the URI scheme for a long time. The File Manager was simply the one place that never got it. **Slice** now appears on every unsliced model (`.3mf`, `.stl`, `.step`, `.stp`) in both the card menu and the list view, and does whichever of the two things your configuration means: with the sidecar on it opens Bambuddy's slice modal and the work happens on the server, and with it off it hands the file to your desktop slicer. The icon says which you will get before you click — a cog for server-side slicing, an external-link arrow for the handoff — and which slicer receives the handoff comes from **Settings → Workflow → Slicer → Open in Slicer**, falling back to the preferred slicer as it always has. The 3D preview goes further, because that is where you are actually looking at the model and deciding: its slicer button is now a split button, and the chevron beside it offers the alternatives without changing any setting. With the sidecar off that is the slicer you did *not* pick as your desktop target; with it on, the primary button still slices server-side and the menu offers a one-off desktop handoff to either slicer. Two things that used to be silent now are not: a handoff refused for want of permission raises an error toast rather than launching the slicer at a URL it cannot fetch, where a permission problem looked exactly like "no slicer installed"; and the file-type rule is shared between the card menu and the 3D preview, so a file can no longer offer **Slice** in one place while showing it greyed out in the other. Permissions follow the endpoint each mode calls — the handoff is a download and needs the same library read permission that lets you see the file, server-side slicing writes a new file and needs upload rights — and the action is shown disabled with the missing permission named rather than hidden. Translated in all locales; wiki updated. Covered by frontend tests. - **Temperatures on the streaming overlay, and a builder for its URL (#1422, reporter @SMAW)** — The overlay at `/overlay/{printer}` draws live print data over a full-screen camera view for OBS, a wall display or any browser source. It could already be tuned — which fields, what size, what frame rate — but only through query parameters documented in the wiki, and temperatures were not among the fields on offer. Both are now addressed. Nozzle, bed and chamber readings join the list, shown with the target while the heater is still climbing and with the target dropped once it is reached, so a settled hotend reads "220°C" rather than "220 / 220°C" for the rest of the print. Both nozzles appear on a dual-nozzle printer. They are drawn whether or not a print is running, since a preheating machine is exactly when they are worth watching, and each reading appears only when the printer genuinely reports it — chamber temperature stays absent on P1 and A1 models, which publish a value with no sensor behind it. And **Settings → API Keys → Streaming Overlay** now builds the URL for you: pick the printer, tick the fields, set size and frame rate, paste in a token if login is enabled, and copy the result, with an optional preview alongside it. The preview stays off until you ask for it so that leaving the settings page open does not hold a viewer on the printer's single camera connection. Making that preview possible needed one narrow change to the security headers: the overlay path now sends `frame-ancestors 'self'` instead of `'none'`, so Bambuddy's own UI can embed it. Every other page still refuses to be framed at all, `'self'` permits a framer only on this same origin, and embedding the overlay from another host — Home Assistant on a different port, say — is unchanged and still requires `TRUSTED_FRAME_ORIGINS`. Temperatures are not in the default field set, so an overlay URL already pasted into a scene looks exactly the same after upgrading. Translated in all locales, wiki updated, covered by backend and frontend tests. - **The external spool can be hidden from the printer card (#1782, reporter @Arn0uDz)** — An external spool holder that never gets used still occupies a full card's width in the **Filaments** row, next to the AMS units that are actually being used. An eye icon at the right-hand end of that row's header now hides it, and clicking it again brings it back, so nothing is lost behind a settings page you would have to remember. The choice is remembered per printer and stored in the browser, like the card size and the offline-printer filter — one machine in a fleet can be tidied up without touching the others, and nothing changes for anyone else using the same Bambuddy. The icon is deliberately absent on a printer with no AMS: there the external spool is the entire filament section, and hiding it would leave an empty row. That guard also covers the case of an AMS being unplugged from a printer whose external spool was hidden earlier — the spool reappears rather than leaving a blank row behind. On the H2D and H2S both external positions share one card and so hide together. Translated in all locales, wiki updated, covered by frontend tests. ### Changed - **The MQTT debug log now records the commands sent to a printer, not only what it reports back** — **Printer → Debug → MQTT** captured one side of the conversation. Bambuddy listens on both of a printer's topics, but the one carrying commands returned before anything was written to the log, so a capture could show every status push the printer made and nothing it was ever told — including the commands Bambu Studio sends over the local network, which is the only place they can be observed at all. Those now appear alongside Bambuddy's own, grouped under the outgoing filter. It is what lets a question like "which value does Studio put in this field?" be answered from a user's capture instead of guessed at, and it is why #2774 could not be taken further. Commands Bambuddy sends appear twice, once as it publishes and once as the broker echoes it back, and the pair is itself evidence the command reached the broker. Logging is off until switched on, as before. Covered by backend tests. - **The L and XL printer cards now scale their text and icons, not just their width (#1848, reporter @misterff1)** — Switching a card from M to XL made it wider, enlarged the printer name and the thumbnail, and left everything else exactly as it was: the AMS slot labels, temperatures, filament names, status text and every small button stayed pinned between 8 and 11 pixels, well under the smallest size used anywhere else in Bambuddy. The result was a full-width card carrying the same tiny text as the compact one, which is precisely the opposite of what someone reaching for a bigger card is asking for. Browser zoom is not an answer to this, since it enlarges the entire page and so preserves the very disparity being complained about. The card body now scales along with the card: L draws it 20% larger and XL 40% larger, icons included, so the controls grow with the text rather than staying fiddly to hit. The AMS-HT card needed two adjustments of its own, since its temperature and humidity readings sit beside the slot rather than under it. Its single slot was the only thing on that row able to grow, so it swallowed every spare pixel and pushed the readings hard against the card's edge — it is now capped at roughly two ordinary slots, which keeps them clear at any card width. The card itself also gained a ceiling of one full AMS card's width, so a unit that wraps onto a line of its own no longer stretches that single slot across the whole card. S and M are deliberately untouched — S is the dense fleet view where density is the point, and M is the default, so an existing install looks identical until you reach for a size that is already asking for more room. Wiki updated. Covered by frontend tests. - **Error and warning toasts now stay up twice as long** — Every pop-up notification disappeared after three seconds regardless of what it said. That is about right for "Settings saved", which confirms something you just did and is skimmed rather than read, but errors and warnings are a different kind of message: they carry a reason, often one relayed from the printer or the backend, and they run to a couple of lines. Three seconds was not long enough to finish reading one, and a missed error message is gone for good — there is no notification history to go back to. Errors and warnings now hold for six seconds. Success and informational toasts keep the three-second default, so the common case of clicking something and seeing it confirmed is unchanged, and the close button and the manual dismiss work exactly as before on all of them. The background print-dispatch toast is unaffected: it stays up while it has work in progress and clears itself shortly after the last job settles. Covered by frontend tests. ### Fixed - **Buttons show a pointer cursor again, and the AMS slot menu stops reshuffling itself (#2791, reported by @AnthonyGrondin)** — Hovering most of Bambuddy gave you an arrow, not the little hand that says "this does something". Not everywhere, though, which is what made it read as sloppiness rather than a bug: the update pill was inert while the buttons beside it were fine, a bed or nozzle tile responded but the history-graph button tucked into its corner did not, and dropdowns went either way with no pattern behind it. The pattern was there. Tailwind v3 gave every button a pointer cursor as part of its baseline styling; Tailwind v4, which Bambuddy has used since the interface was built, deliberately dropped that rule to match what browsers do on their own — and browsers give a button the ordinary arrow. From then on a button only looked clickable if whoever wrote it had said so by hand. Fifteen of about nine hundred and thirty had. None of the hundred and forty-nine dropdowns had, and of the checkboxes and radio buttons, nineteen out of a hundred and thirty. The rule is now restored once, centrally, rather than pinned onto individual buttons for the rest of the project's life: buttons, dropdowns, checkboxes, radio buttons, disclosure arrows and anything explicitly marked up as a button all point again. It sits at the bottom of the styling order, so the places that deliberately show a "not allowed" cursor on a disabled control still win, and a control that is genuinely disabled is left alone. Modal backgrounds are deliberately untouched: clicking one closes the dialog, but a full-screen sheet that claims to be a button is worse than one that says nothing. Separately, and behind the same report: the menu on an AMS slot listed **Configure** above **Assign Spool** on an empty slot and the other way round on a filled one, because the two are drawn by different code that had quietly drifted apart — both now lead with the spool action, and a test pins each side so they cannot drift again. The buttons in that menu centred their own text, which left their icons in a ragged column; they are aligned to the left edge now. Their hover shading was a ten-percent step that was very hard to see, and is now twice that. And the star on **Add to favourites** turns yellow as you hover it, so it previews what clicking will do. - **A job queued to "Any {model}" now switches a printer on, like a job queued to one printer always has (#2786, reported by @TheUltimateC0der)** — Queue a print against a printer class -- **Any X1C**, or a Slicer Pipeline whose target type is **Printer class** -- with every printer of that class switched off at the wall, and nothing happened. The job sat pending, no smart plug was touched, and the only way out was to edit the item onto a specific printer, at which point Bambuddy powered that printer on immediately. The reporter's log holds that comparison exactly: thirteen minutes of the job being polled and passed over, then the edit, then a power-on on the very next check -- same job, same plug, same Auto Power On setting. Powering a printer on had only ever been written into the branch that handles a job pinned to one printer; the branch that picks a printer by model listed an offline one as a reason to keep waiting and never looked at its plugs. It does now. It also picks with a little more care than the older branch: a printer waiting for a plate-clear acknowledgment is passed over, because switching it on only leaves it idling behind that gate -- which is what the reporter's own log shows happening for the eighty minutes after their manual edit -- and a printer whose class the file cannot legally run on is never switched on at all. One printer comes up per queue check rather than a whole shelf at once, so several queued jobs wake several printers over the following minutes. Finally, a printer that is off and has no enabled Auto Power On plug now says that in the job's waiting reason instead of hiding behind the same "Offline" as the printers Bambuddy can bring back itself -- that distinction was the first question the reporter had to be asked. - **A printer whose file service stops answering is named as such instead of quietly emptying your archives (#2780, reported by @Utility9298 and @AntonPalmqvist)** — Two printers went on printing normally while every archive they produced arrived holding nothing but a filename: no filament totals, no layer count, no cover image, no timelapse. The Connection Diagnostic reported the file-transfer port as reachable, because it was — the printer accepted the connection and then answered it with something that was not TLS at all, and the sliced file could never be read back. Bambuddy said nothing about that on screen; it retried. Because each candidate location opened its own connection, one reporter's log carried 1813 identical handshake failures and another's 3511, against a printer that could not have answered any of them. This is not a model or a firmware problem — the same printers worked for days before and after the fault, and other installs run the same models untouched. It is the printer's own file service getting stuck, and a power-cycle clears it. Bambuddy now stops after the first failed handshake and leaves that printer alone for five minutes, so the log carries a handful of entries that say what went wrong and what to do about it rather than thousands that say neither. The Connection Diagnostic now completes a real handshake instead of merely opening the port, so a printer in this state reads as a warning that names a restart — not as a green tick. Scanning for a timelapse on such a printer reports the file service, where it used to return one error message that covered both "the printer is unreachable" and "this printer has no timelapse folder", and asking for a cover image says the same thing instead of the "no cover for this print" it used to claim. Printing is unaffected throughout: the control connection is a separate service, which is exactly why the fault was invisible. - **Prints queued from a Slicer Pipeline or the Library are checked for enough filament again (#2779, reported by @wylyn3d)** — A job needing 20.5 g was dispatched onto a spool holding 9 g, and the printer started. The same file, printed from the Print dialog, was correctly refused. The check that stands between the queue and the printer reads the sliced file to learn how much each slot needs, and it looked for that file in the wrong place: a file in the Library records where it lives relative to Bambuddy's data directory, and this one check read that as a path from wherever the process happened to be running. It found nothing, and a source it cannot find has always meant "nothing to verify" rather than "stop" — so the job passed a check that never actually ran. Every path that queues a Library file was affected: Slicer Pipeline jobs, which are always Library-backed, and anything added through the Library's **Add to queue**. Both the automatic dispatcher and the Play button on the queue were equally blind, so the deficit could not be caught by starting the job by hand either. Prints queued from print history were never affected, and neither was the Print dialog, which finds the file its own way. Two things changed: the check now resolves a Library file the same way the eleven other places that read one already did, and a source file that is configured but missing now writes a warning to the log naming the item and the path it looked at. That case still dispatches — the upload needs the same file moments later and fails there, where blocking would strand a queue on a file the user may have moved — but it no longer passes in silence, which is what let this go unnoticed. Covered by backend tests, including the reporter's exact 20.5 g against 9 g. - **A Forgejo token limited to a single repository can now be used for backups (#2775, reported by @AnthonyGrondin)** — Forgejo v15 lets you mint an access token that only reaches one repository, which is the safest token you can give a backup: leak it and the damage stops at the repository it was made for. Bambuddy refused it. **Test connection** asked Forgejo who the token belonged to before it asked whether the token could reach the repository, and a repository-scoped token is not allowed to answer that question — it may only carry read and write on issues and repositories — so the check failed on a token that would have backed up perfectly well. The identity question is now asked but no longer decides: only an outright rejection of the token is conclusive, and everything else falls through to the repository check, which is the one that matters. Nothing else in a backup ever needed the wider permission — the push writes through the repository's own contents endpoint and a restore reads its commits, trees and blobs — so ordinary tokens are unaffected. The message shown when the repository cannot be reached now names the scope to look for and the possibility that the token is scoped to a different repository, rather than only explaining Forgejo's habit of reporting a private repository as missing. The hint under the token field is also per provider now: it read "fine-grained token with Contents read/write" for all four, advice that only ever applied to GitHub, and now names GitHub's, GitLab's, Gitea's and Forgejo's own scopes in every language Bambuddy speaks. Covered by backend and frontend tests. - **Files queued from the Library are no longer missing from their owner's queue** — On an installation with authentication turned on, a user whose permissions are scoped to their own work saw an empty queue after adding files from the Library, and adding more only added more nothing. The jobs were really there and really printed; they simply belonged to no one. Every queue item records who created it, and the "own queue" permissions decide what to show by comparing that against the signed-in user — but the Library's bulk **Add to queue** never wrote it down, so its items matched no one and were visible only to users who can see the whole queue. This affected the one path built for adding many files at once, which is where it was hardest to notice something was wrong: the file list on screen looked no different afterwards. The same omission applied to the queue endpoint of the webhook API, whose items are now credited to the owner of the API key that added them. Items that genuinely have no one behind them are unchanged and still belong to no one — jobs sent through a virtual printer, anything added while authentication is off, and keys created before API keys had owners. Covered by backend tests. - **The drying popover no longer starts a cycle under a material you did not pick (#2774)** — An AMS-HT loaded with Support for PLA/PETG offered PLA in the drying dialog's filament list, and the cycle that started was labelled Support for PLA/PETG on the printer's own screen. Opening the dialog prefills it from the spool that is loaded, and Bambu reports that spool's material as `PLA-S` — a name Bambuddy's table of drying temperatures does not carry. The temperature and duration fell back to PLA's 45°C for twelve hours, which is what the dialog showed and what was sent, but the material did not fall back with them: it stayed `PLA-S`, and the dropdown, handed a value that is not one of its options, displays its first option without saying so. So the list read PLA while `PLA-S` was what left for the printer. Anyone who opened the dialog and pressed **Start** without touching the material was affected; picking any entry from the list, even the same one it was already showing, made the two agree again. The same gap covered every composite — a spool of PETG-CF, PLA-CF, ABS-GF or PAHT-CF prefilled the dialog at PLA's 45°C, far short of what those materials want, and sent its full name as the material. Bambuddy now resolves a spool's material to an entry the list actually has before either value is set, so what the dialog shows and what the printer is told can no longer disagree. Support materials and composites resolve to the material they are built on, so PLA-S dries as PLA and PETG-CF as PETG at 65°C, and nylon is recognised under the several spellings Bambu gives it. Anything genuinely unrecognised still falls back to PLA, deliberately the coolest setting in the table — under-drying an exotic filament costs a cycle, where defaulting to the hottest would deform a PLA spool. This does not address the other half of that report: a printer that keeps showing the material set from its own screen even when Bambuddy names a different one, which needs a capture of what Bambu Studio sends before anything can sensibly be changed. Covered by frontend tests. - **Configuring an AMS slot shows up on the printer card straight away, without a page reload** — Setting a slot to a different filament from the printer card left the card showing the old one. Nothing was lost: the command reached the printer, the printer applied it, and reloading the page or waiting out the thirty-second fallback poll showed the new filament. It simply never arrived on its own. Bambuddy compares each status push from a printer against the last one it broadcast and stays quiet when nothing has changed, which is what keeps a machine mid-print from flooding every open browser tab several times a second. The comparison looked at each AMS tray's slot number, material and load state — and **Configure Slot** writes none of those. It writes the filament id, the colour, the profile name and the calibration profile. So changing PLA to a different brand or colour of PLA produced a push that looked identical to its predecessor and was discarded, while changing PLA to PETG came through immediately because the material had moved. That is also why **Reset** always worked: it clears the material. The comparison now covers the filament identity as well, so every kind of slot change reaches the card. Those fields only move when someone configures a slot or swaps a spool, so this adds no traffic during a print — the amount of filament left, which does tick down continuously, is still deliberately excluded. Covered by backend tests. - **"Any X2D" works on a printer that feeds from external spools instead of an AMS (#2771, reporter @Nick-C130)** — A fleet of five X2Ds with no AMS units, each printing PETG from its external spool holder, accepted a job sent to a named printer and refused the same job sent to **Any X2D**: the file uploaded, the printer answered "Failed to get AMS mapping table", and after three attempts the queue item failed. The two paths differ in one thing. A job queued for a named printer carries a filament mapping the browser worked out at the time you queued it; a job queued for a model has no printer yet, so the scheduler has to work the mapping out at dispatch — and its copy of that logic could not see an external spool on a dual-nozzle printer. On an X2D or H2D each filament in the sliced file names the nozzle it feeds, and Bambuddy will only offer a spool to the nozzle it is physically plumbed to. Which nozzle an external spool feeds was being read off a table the printer builds from its AMS units, so a printer with no AMS published an empty table, every external spool came back belonging to no nozzle at all, and the per-nozzle check discarded the only filament on the machine. Nothing matched, and the print went out claiming to use an AMS while carrying no mapping — which is the message the firmware was objecting to. The left and right external feeds identify themselves well enough to be routed without that table, and the printer reports its two nozzles directly, so both are now used. This is the same fault that was corrected in the browser last May for exactly this hardware; the scheduler kept the old logic, which is why the browser-resolved mapping worked and the scheduler-resolved one did not. Single-nozzle printers are untouched — they have no nozzle to route to and never took this branch. Separately, a job whose filament genuinely cannot be matched on a printer with no AMS now fails immediately and says which filament is missing and which nozzle wants it, instead of uploading several megabytes, collecting the firmware's error and failing anyway two retries later; where there *is* an AMS the firmware error still stands, because there the job can be recovered by loading a spool and pressing **Resume**. Covered by backend tests. - **LDAP login works again on directories that define no POSIX group class (#2769, reporter @peterskotte)** — Every LDAP user on an lldap directory was rejected with "Incorrect username or password", including users whose credentials, search filter and group membership all checked out when tested by hand with `ldapsearch`, and on an install where **Test Connection** reported success. The password was never the problem and the directory never saw the request. When resolving a user's groups Bambuddy looks for POSIX groups alongside the usual `memberOf` ones, and both of those searches name the `posixGroup` object class. The LDAP client validates class names in a filter against the schema the server publishes, and rejects an unknown one while building the request, before anything is sent. lldap marks every account it creates as `posixAccount`, which is what makes Bambuddy look for POSIX groups in the first place, but defines no group class beyond `groupOfNames` — so the search was refused, the refusal travelled all the way out of the login routine, and the login route reports any LDAP failure as bad credentials. A directory with no `posixGroup` class has no `posixGroup` entries, which is precisely the answer those searches would have returned, so Bambuddy now treats the refusal as the empty result it stands for, notes it once in the log and carries on with the `memberOf` groups. The reporter's mapped group is one of those, so it resolves as configured. This is not a regression from the recent primary-group work, though that is the natural suspect: the `memberUid` search has named the same class since LDAP support first shipped, and it runs for every user whether or not they have a `gidNumber`, so login has never worked against a directory of this shape. **Test Connection** passed throughout because it asks only whether any entry exists, a form of filter that carries no class name to validate. Nothing changes for Active Directory or for an OpenLDAP that loads the standard NIS schema — both define the class, and their POSIX groups are still read. Wiki updated. Covered by backend tests. - **Spoolman no longer charges a Bambu Studio print to the wrong spool (#2768)** — A sliced file numbers its filaments 1, 2, 3, 4, and which AMS tray each of those came from is a separate decision made when the job is sent. Bambuddy learns that decision one of two ways: it made the choice itself, for a print started from Bambuddy, or it read the print command as it crossed the local network, for a print sent from a slicer. A job dispatched from Bambu Studio while the printer is signed in to Bambu's cloud satisfies neither — the command travels through Bambu's own broker and never appears on the network Bambuddy is listening to. With nothing recorded, the Spoolman writer fell back to assuming the AMS was loaded in slicer order: filament 1 from the first loaded tray, filament 2 from the second. The reporter's X1C was loaded in the order 2, 4, 1, AMS-HT, so every one of the four was deducted from the wrong spool. It also changed what the print looked like afterwards: on completion Bambuddy stamps the archive with the material and colour of the spools it charged, so the print showed the right filament while it ran and switched to a different one the moment it finished — which is how the reporter noticed. The printer knew the answer all along. It publishes the running job's slot-to-tray assignment in its own status, and Bambuddy's built-in filament inventory has read that field for as long as it has resolved mappings at completion; only the Spoolman writer, which resolves at print start instead, never learned to. It now consults the same two fallbacks at the same moment: the printer's report first, and failing that a colour match of the sliced filaments against the loaded trays, which covers the A1, A1 Mini, P1S and P2S — those models publish no such field, so their owners were on the positional guess no matter how the print was sent. Reading the field at completion rather than at print start is deliberate: a printer keeps publishing the last job's mapping while it sits idle, so consulting it early risks stamping the previous print's mapping onto this one. A mapping Bambuddy or the slicer actually recorded is never second-guessed, so nothing changes for prints started from Bambuddy, from the queue, or over LAN. Cancelled and failed prints take the same correction, since partial usage is charged through the same mapping. The resolved mapping and where it came from are now logged at both print start and completion, so the next report of a wrong deduction can be read straight out of a support bundle. Wiki updated. Covered by backend tests. - **A drying cycle the printer abandons now says so, and says what the printer reported (#2770, reporter @tchavei)** — An H2D started a twelve-hour PETG dry at 65°C and the AMS gave up on it twenty minutes in, with 700 of the 720 minutes still on the clock. It then cooled off, humidity climbed back over the threshold, auto-drying started another twelve-hour cycle, and that one was abandoned the same way — a loop the reporter's AMS temperature history shows running all morning. The log had one line to say for it: `AMS 0 drying complete`, which is exactly what it says for a dry that ran its full twelve hours. Nothing in a support bundle told the two apart, and the remaining time — the one number that does — was written into the line as the *previous* value, where it reads like a duration rather than a shortfall. Bambuddy did not stop that cycle. Every stop it sends is logged with the full command as it goes out, and there was none, so ending it was the printer's decision — and the only account of why lives in three things Bambuddy already receives and parses but has never written down: the drying phase and sub-phase the AMS reports in its status word, the firmware's own cannot-dry reason codes (which distinguish an overheating unit from one being starved of power by a missing external supply), and whatever HMS errors are live at that moment. A cycle that ends with most of its countdown left now logs all three, alongside how much of the requested duration actually ran and how much was asked for. A cycle that reaches its configured duration keeps the single line it has always had, so a normal dry does not start reporting diagnostics nobody needs, and a cycle Bambuddy itself ends — the print-takes-priority stop, or the **Stop** button — now says so by name rather than being reported as an unexplained early end, since a stop is short of its duration too and looks identical in the telemetry. This is diagnostics only: nothing about when drying starts or stops has changed, and the repeated restart itself is not addressed here — what the firmware objects to has to be established before Bambuddy can sensibly decide how long to wait before trying again. Covered by backend tests. - **A drying cycle no longer reports itself finished a minute after it starts (#2759)** — Starting the dryer on an AMS 2 Pro holding two PETG and two PLA spools and picking PLA showed "PLA @ 45°C" for about a minute, then switched to "PETG @ 65°C" for the remaining twelve hours. Bambu never echoes back which filament or temperature a cycle is running, so the badge reads the target Bambuddy cached when it sent the command — and that cache had been thrown away. Between accepting the command and settling its countdown the firmware publishes one update with the remaining time at zero while the unit is still in its Checking phase; the reporter's log caught 720 minutes, then 0, then 719. Bambuddy read the zero as the cycle ending. Losing the cached target left the badge to guess the filament from the first loaded slot, which happened to be PETG, and its RFID-recommended 65°C — a confident wrong answer for a cycle running PLA at 45. The same false ending also armed smart-plug auto-off-after-drying, so anyone with that switched on had power scheduled to cut one minute into a twelve-hour dry. A remaining time of zero is now only treated as the end of a cycle when the AMS also reports an idle phase, which the firmware already publishes alongside it; stopping a dry early still ends it immediately, and a unit that reports no phase at all still ends its cycles as before. The fallback guess has been tightened to match, in both directions. It names a filament only when every loaded spool agrees on one — on a mixed unit the badge shows the countdown alone rather than naming a spool the cycle isn't drying — and it no longer guesses a temperature at all. A unit loaded entirely with PLA does tell you what is being dried, but not at what temperature: that is picked freely when the cycle is started, so the spools' RFID-recommended value is never evidence of it, and a second AMS loaded only with PLA and drying at 45°C still read "PLA @ 55°C" whenever the cached target went missing. The badge now names a temperature only when Bambuddy sent it, and shows the filament and countdown without one otherwise. Covered by backend and frontend tests. - **A print that never starts now says AMS drying was running, instead of blaming the SD card (#2758)** — Sending a job to an X2D with two AMS units mid-drying failed silently: the file uploaded, the printer accepted it and then simply stayed idle. Bambuddy waited out the start watchdog, re-uploaded the whole 3MF, waited again, and after three attempts gave up with advice to check the printer's screen and the SD card — while Bambu Studio, asked directly, said it could not start the job because of the drying. Bambuddy now watches the AMS drying telemetry it already receives across the dispatch window and, when a job never starts while a unit was drying, names the units in the failure message and records the correlation in the log from the first attempt rather than only after the retries are spent. This is deliberately a diagnosis and not a rule: the printers concerned support drying *continuing* through a print, so drying and printing are not in conflict as such, and the report also involved one AMS drying without its external power supply — which would make the start-of-print calibration a power problem rather than a drying one. Stopping the cycle automatically would therefore be acting on a guess, and could tear down drying the hardware was happy to continue. Until it is known which of the two is the real obstacle, Bambuddy tells you what it saw and leaves the call to you. The message for a stalled dispatch with no drying involved is unchanged. Wiki updated. Covered by backend tests. - **A hand-written systemd service left the Virtual Printer unable to start, with nothing obvious to blame (#2549, reporter @Ru3ck3)** — The Virtual Printer binds ports 990 and 322, both below 1024, which a service running as a normal user may not do without the `CAP_NET_BIND_SERVICE` capability. Without it the rest of Bambuddy works perfectly and only the Virtual Printer is dead: its sockets never open, the slicer never finds the printer, and the sole trace is one line in the journal. The reporter lost days to this before someone on Discord spotted the missing line. The install script has carried it since March, but the three other places that define the same service did not — the manual-install template, the combined Bambuddy plus SpoolBuddy installer, and the unit the wiki tells you to paste. All three have it now, and the wiki no longer claims the capability is always included when its own instructions omitted it. Bambuddy also diagnoses this itself: **Diagnose** on the virtual printer card previously reported only that nothing was listening on port 990, which reads identically to an ordinary port conflict. It now checks whether the process actually holds the capability and, when that is what is wrong, says so and gives the line to add. The check stays quiet when the port is answering, since fronting it another way (an iptables redirect is the documented alternative) is a legitimate setup, and it stays quiet when the capability is held, so a port that failed for some other reason is not misattributed. Existing installs are unaffected until reinstalled; the diagnostic tells you whether yours needs the line. Translated in all locales; wiki updated. Covered by backend tests. - **A refused AMS filament setting now says so in the log (#2756, reporter @Jostxxl)** — Configuring a slot publishes an `ams_filament_setting` command, and the printer answers it with a verdict. That answer was received and then thrown away at debug level, so a printer that refused the write left no trace at the log level support bundles are collected at. The reporter hit exactly that: six manual **Configure Slot** attempts on one X1C, every one returning success, every one read back by the #2582 verification as still holding the previous profile, and nothing anywhere to say what the printer had made of the command. A refusal is now logged with the printer's own `result` and `reason` alongside the AMS and tray it concerned. Only refusals are promoted — unlike the K-profile and drying commands this one is not rare, since every spool assignment and every K-profile re-apply sends one, and logging each acknowledgement would bury the line worth reading. The developer-mode probe is excluded as well: it sends this same command to the external slot specifically to watch it be refused on P1 firmware, so its failure is a measurement rather than a fault. Diagnostics only — nothing about which commands are sent or how they are built has changed. Covered by backend tests. - **Live updates stopped arriving while the Bambuddy tab was in the background (#2754, reporter @mic4rd)** — The progress percentage in the tab title froze whenever you switched away and jumped straight to the current value the moment you came back, which defeats the point of putting it in the title. There were two causes, and the first fix only got one of them. Every printer status arriving over the WebSocket was written into the browser's cache from inside an animation-frame callback, and a browser gives a hidden tab no frames at all — those callbacks are not slowed down, they are held, so the connection stayed up, the messages kept arriving, and every one of them parked in a queue that only ran when the tab was shown again. The same applied to the archive, inventory and spool refreshes, and to the queue carrying every non-status message, which stalled completely. Removing the frames fixed that stall but not the report, because the write still went through a 100 ms timer that batches rapid updates — and a timer is exactly what a browser throttles in a tab you are not looking at, to roughly once a second, and to about once a minute once the tab has been hidden for five minutes. The reporter's screenshot showed a tab title reading 2% beside a page at 40%. That batching exists to stop a burst of messages causing a rendering pile-up, and a hidden tab is not rendering, so there is nothing to protect there: while the tab is hidden the value is now written straight through, and the batching still applies while you are looking at it. Worth knowing if you use Windows: a browser window completely covered by another window counts as hidden, not merely unfocused, which is why this could bite without ever switching tabs. Covered by frontend tests that reproduce a hidden tab, including one that never advances the clock — the earlier tests passed by simulating the very timer the browser was throttling. - **The bug-report button no longer covers the controls in the bottom-right corner (#2750, reporter @goodjaltman)** — On a phone the floating red button sits on top of whatever else is in that corner, which turns out to be most things: the scroll-to-top button on Profiles was ~83% underneath it and, since both sit at the same stacking level, which one you could actually tap came down to the order they happened to render in. The floating camera window parks there, as do the Group Edit save bar, the bulk-selection toolbars, and — because the button is pinned to the viewport rather than the page — the per-card action buttons on File Manager and Archives simply scroll underneath it. The reporter asked for a switch to hide the button, but it is the only way into the report form, and that form is not just a text box: it runs the printer connection diagnostic, scans your logs against the known-issue catalog, optionally captures five minutes of debug logging and attaches a support bundle. Hiding it doesn't produce smaller reports, it produces reports with nothing attached. So the button moves instead of disappearing. Once the window is narrow enough that the sidebar collapses into a menu button, the bug icon moves into that top bar and the corner is left alone; above that width nothing changes. That threshold is the one the layout already switches on, so there is no new breakpoint and no third state to reason about, and it covers tablets and half-width desktop windows rather than only phones. The report form itself is now a proper bottom sheet on phones, which also fixes it hanging 16 pixels off the left edge of the screen — it was sized to the full viewport width and then inset from the right, so a strip of the form was simply unreachable on anything under about 460 pixels wide. The scroll-to-top button on Profiles has been nudged clear of the corner as well, for the wide layouts where the floating button stays. Wiki updated. Covered by frontend tests. - **The Print Log's cost and energy figures were never sent to the browser** — Bambuddy has been recording what each run cost and how much power it drew, but the two Print Log endpoints built their responses field by field and never mentioned `cost`, `energy_kwh` or `energy_cost`. A field nobody names comes back as its default, so the values arrived as nulls — indistinguishable from a column that genuinely holds nothing, with no error and no log line to say otherwise. The same trap had already swallowed the failure-cause classification once before. Both endpoints now validate straight off the database row, which removes the opportunity to forget a field rather than fixing the three that happened to be missing. Existing rows need no migration: the data was always there. Covered by backend tests. - **The Print Log is reachable again once you have no archives** — The Archives page decided it had nothing to show before it checked which view you were on, so with zero archives the "No archives yet" card replaced every view including the log. The Print Log is a separate table that deliberately outlives the archives it refers to — deleting an archive only clears the reference, and clearing the log is its own action — so purging archives hid a history that was still in the database, with no way back to it short of re-adding an archive. The log view now renders its own empty state instead of borrowing the archive one. Wiki updated. Covered by a frontend test. - **Chamber temperature can now be set up to 65 °C, not 60 (reported on Discord)** — Every field in Bambuddy that takes a chamber target stopped at 60 °C: the per-filament chamber map and the per-print chamber override in **Preheat & Heat Soak**, the chamber quick-select presets, and the chamber temperature control on the printer card. 60 is the ceiling for the X1E, which was the only heated-chamber model when that limit was written; the H2 series (H2C, H2D, H2D Pro, H2S) and the X2D heat to 65, so the top of their range was simply unreachable — an ABS or PA profile calling for 65 had to be run at 60. The ceiling is now 65 everywhere, held in one constant on each side rather than repeated as a literal at every call site, so the four surfaces cannot drift apart again. X1E owners are unaffected: its firmware clamps a higher request to its own maximum. Wiki updated. Covered by backend tests. - **The Settings page no longer reverts settings changed from anywhere else (#2716, reporter @jmoore-skild)** — While the Settings page was open it held its own copy of every setting and only ever took one from the server, on first load. A background effect then compared that copy against the server's and saved the whole thing back on any difference — with no way to tell "the user edited this field" from "this field changed on the server". So anything written while the page sat open was silently undone: a change made in a second tab, another user's change on a shared install, a restore from a backup. It needed no click to trigger. The page's data goes stale after a minute and refreshes when the window regains focus, and around thirty other places in the app read the same settings, so a refresh from any of them was enough — after which the page wrote its page-load copy back over all 77 settings it manages, and showed **Settings saved** while doing it. The page now keeps track of the last server state it reconciled with. A field still matching that state has not been touched, so a newer value from the server is adopted and displayed; a field the user has edited keeps their value and is saved over the top, so the newer of the two writes wins either way. Typing into a text field while a refresh lands is still safe, which is what the old behaviour was protecting. Covered by frontend tests. - **A rejected K-profile write is now reported as rejected (#2718, reporter @jmoore-skild)** — Saving a K-profile was fire-and-forget: Bambuddy published the command and reported success the moment the bytes left the process. The printer does answer, and the answer was received, matched, and thrown away at debug level — so a write the printer refused for a real reason still told you it was saved. The complication was that the answer itself was wrong: on single-nozzle printers it came back `result: "fail", reason: "invalid tray_id"` on writes that demonstrably applied, which made gating on it look impossible. Measuring against an X1C and an H2D found the cause — the `tray_id: -1` Bambuddy itself put in the payload. The X1C's firmware validates that field and rejects the value while applying the write anyway; the H2D ignores it. Sending `0`, as BambuStudio does, makes the acknowledgement honest, and the printer echoes back the sequence number we sent, so it can be matched to the write that caused it. Saving or deleting a profile now waits for that answer and surfaces a genuine rejection as an error instead of a success toast. A printer that stays silent is still treated as success — no answer is not evidence of refusal. The acknowledgement is also logged at INFO now, so it appears in a support bundle. Covered by backend tests. - **The K-profile flow type is a real choice again** — On most printers the calibration table comes back with no nozzle identity at all, and Bambuddy had started showing "Not reported by printer" in the Flow Type field as a result. That is not a value you can save, and it isn't what the slicer does: BambuStudio treats a missing nozzle identity as **Standard** and leaves the choice editable. Bambuddy now does the same. The field is hidden only on models sold with a single nozzle variant — the A1, A1 Mini and A2L — using the same rule the slicer applies. This is not the single-versus-dual-nozzle split: the P1P, P1S, P2S, X1, X1 Carbon, X1E and H2S are all single-nozzle and all offer both flows. Editing a profile also no longer strips the nozzle identity from what it writes back. - **Dialogs no longer act after they have closed** — The AMS slot configuration and K-Profile dialogs hold their success state briefly and then close themselves, between 1.5 and 4 seconds after the command is sent so the printer has time to process it. That timer ran whether or not the dialog was still open, so dismissing it — or the printer card refreshing underneath it — within that window left a pending close that fired later, dismissing whatever dialog happened to be open by then. The deferred close is now cancelled when the dialog goes away. Covered by frontend tests. - **A printer with no K-profiles can now be given its first one (#2719, reporter @jmoore-skild)** — **Add K-Profile** built its Filament dropdown out of the profiles already on the printer, so on a printer with none the field was empty, required, and impossible to satisfy — the modal even said so, telling you to go and create the profile in Bambu Studio instead. The filament picker is now populated the way every other one in Bambuddy is, in the same order: **Imported** presets first, then **Orca Cloud**, then **Bambu Cloud**, then Bambuddy's built-in Bambu filament table. That last tier is compiled in, so the list is never empty — a brand-new printer with no cloud account and nothing imported still gets you a profile. The per-printer-model copies a cloud account carries ("Bambu PLA Basic" once for the X1C, once for the P1S, once for the A1) are collapsed into a single row, and the built-in table — a static copy of the same Bambu catalogue — no longer echoes back filaments the groups above already list. Your imported and Orca Cloud libraries are both shown in full even where they overlap by name, because they are usually the same profiles reached two ways and each group is worth seeing under its own heading. The picker is a searchable list with the source heading shown as a real, legible group header — a native dropdown can't do that, since browsers render the group label of a `` in small grey italics and ignore styling on it. Imported and Orca Cloud presets carry no Bambu filament ID, and the printer indexes its calibration table by one, so those are filed under the closest generic for their material — the same rule the AMS slot configuration already uses, so a profile created here matches the slot configured there. A filament whose material Bambuddy can't place is refused with an explanation rather than written under a wrong ID. Also drops a second, redundant K-profile fetch the old dropdown needed: it ran concurrently with the main one whenever a non-0.4mm nozzle was selected, which is exactly the case that made K-profile requests time out. Translated in all locales; wiki updated. Covered by frontend tests. - **K-profiles no longer all report 0.4mm / High Flow (#1748, reporters @Liquidmasl and @jmoore-skild)** — On any printer running a nozzle other than 0.4mm, every K-profile showed up as `0.4` with a flow type nobody had set, and the same profile disagreed with itself: the list said **S**, the edit dialog said **High Flow**. The printer reports the nozzle diameter once, on the response envelope; the individual profile entries carry no diameter and no nozzle id at all. Bambuddy read the diameter *per entry* and fell back to a hardcoded `0.4` when it wasn't there — which was always. The envelope value is now used, so profiles report the nozzle they were actually calibrated for. This was not only cosmetic. Editing a profile is delete-and-re-add on single-nozzle printers, and the dialog rebuilt the nozzle fields from its own (greyed-out) dropdowns, so saving an untouched 0.6mm profile rewrote it on the printer as 0.4mm High Flow. Deleting one aimed the command at the wrong nozzle for the same reason. Both now pass through exactly what the printer reported. Assigning a spool's stored calibration to an AMS slot was affected too: that lookup matches on nozzle diameter, so on a 0.6 or 0.8 nozzle it never found the printer-side entry and the cali_idx silently failed to stick — the "can't auto-map a K-profile" half of the report. Where the printer sends no nozzle id, Bambuddy now says so instead of picking one: the list shows the diameter alone, the dialog shows **Not reported by printer**, and the High Flow / Standard filter is hidden rather than offered as a control that can only ever empty the list. Also fixes the flow type filter selecting the opposite label when naming a new profile. Translated in all locales. Covered by backend tests. - **K-profile requests no longer time out when two run at once (#1748)** — Fetching profiles for one nozzle size while another fetch was open made the first one time out, with `Failed to get K-profiles after 3 attempts` in the log, even though the printer had answered both correctly. Responses were matched to requests by nozzle diameter held in a single shared slot, so the second request overwrote the first's expectation and the first's valid answer was discarded as a mismatch. Requests are now correlated by the sequence id Bambuddy already sends and tracked one entry per request, with the old nozzle match kept as a fallback for firmware that doesn't echo the id back. An unsolicited broadcast arriving mid-fetch also no longer replaces the profile list the fetch is waiting on. Covered by backend tests. - **Git backup now actually writes cloud profiles (#2717, reporter @jmoore-skild)** — Enabling **Cloud Profiles** for a Git backup produced nothing. The collector looked for a `setting` list in the Bambu Cloud response, which is keyed by preset type instead, so the loop never ran once — and it asked for the credential store used when authentication is *disabled*, so on any install with authentication on it found no account to collect from in the first place. Neither failure was visible: `backup_metadata.json` still recorded `cloud_profiles: true`, and the log line read `Collected cloud profiles: 0 filament, 0 printer, 0 process`, which looks like a successful backup of an empty account. Cloud profiles are now collected from **every connected account across both Bambu Cloud and Orca Cloud**, one directory per cloud per account, keyed by user ID so no email address is written into a backup repository. Bambu presets are stored with the payload needed to recreate them rather than just their names, and Bambu's bundled public catalogue is skipped — it is identical for everyone, re-downloadable, and would rewrite the repository on every run. The metadata now records what was actually collected, per cloud and per account, and a run that collects nothing while the category is enabled says so as a warning instead of an INFO line that reads like success. The Cloud Profiles checkbox no longer keys off your own Bambu sign-in — it enables when *any* account is connected and shows how many are in scope, which matters on a multi-user install where the presets being backed up are other people's. A backup also no longer disconnects an Orca Cloud account whose session it can't refresh: Orca reports every rejection with one composite reason, so a genuine revocation is indistinguishable from a lost token-rotation race, and an unattended job should not be the thing that guesses. The account is skipped with a warning, and the dead credentials are cleared the next time you open Orca Cloud Profiles — where you can pair again on the spot. Translated in all locales; wiki updated. Covered by backend tests. - **A heavy model failed to slice after five minutes with "Slicer sidecar unreachable" (#2730, reporter @kpp39)** — A MakerWorld model that Bambu Studio also takes a long time over never finished slicing in Bambuddy: five minutes in, it failed claiming the slicer sidecar could not be reached. **Root cause.** The slice request carried a fixed five-minute limit covering the whole operation, and it was applied to the wrong thing. Slicing is a single long request, so the limit was a ceiling on how long a model was allowed to take — not a check on whether anything had gone wrong. When it expired, the resulting error was indistinguishable from a genuine connection failure, so a slice that was progressing normally was reported as an unreachable sidecar. The reporter went and updated their sidecar container, which was never the problem: it was reachable throughout and still slicing when Bambuddy hung up on it. **Fix.** Bambuddy already polls the sidecar once a second for progress — that is what drives the live progress toast — so it can tell a slow slice from a stuck one, and now does. The limit applies to *silence*: a model that keeps reporting progress runs to completion however long it takes, and a slice is only abandoned when the slicer has said nothing for the configured period. The new **Slicer stall timeout** under Settings → Workflow → Slicer sets that period, defaulting to fifteen minutes, and the failure message now says the slice ran out of time and where to change it rather than blaming the connection. Sidecars too old to report progress have no liveness signal to offer, so for those the setting still bounds total slicing time — the previous behaviour, but configurable and no longer five minutes flat. A sidecar that genuinely cannot be reached still fails immediately and still says so. Translated in all locales; wiki documents the setting. Covered by tests for a slow-but-progressing slice completing, a stalled one failing, a frozen progress report not counting as progress, the two failure messages, and the setting falling back safely when unset or unparseable. - **Deleted prints stayed in their project as cards with broken previews, and could not be removed (#2731, reporter @sroesner)** — Deleting a print that belonged to a project left it on the project page with a missing thumbnail, and there was no way to unassign it. **Root cause.** Deleting a print is a soft delete by default: the files go from disk, the row stays so Quick Stats keeps counting its filament, time and cost. Every other part of Bambuddy skips those rows — the projects module skipped none of them, so a deleted print kept its project link and kept being listed, pointing at a thumbnail that no longer existed. The same broken previews appeared on the project cards in the overview, not just the detail page, and in the project timeline, where clicking the entry led to an archive that no longer opens. Unassigning was impossible because the only way to change a print's project is from the Archives page, which correctly hides deleted prints — so the entry could be seen but never reached. **Fix.** A deleted print now leaves its project everywhere: the archive list, the card previews, the timeline, and the counts. Excluding it from the *counts* is a deliberate difference from how Quick Stats treats the same print — a project is a piece of work with a definite membership rather than a lifetime total, so a project that lists eleven prints should not claim twelve. Existing broken entries disappear on upgrade with nothing to clean up; the API can still clear a stale link if anything needs repairing. Two more places were counting deleted prints for the same reason and are fixed with it: the archive CSV/Excel export handed back rows the interface says are gone, and per-project failure analysis measured a failure rate against prints that had been deleted from the project. The project page also no longer needs a manual reload to catch up: deleting a print refreshed the archive list but nothing project-related, and assigning a print to a project refreshed the project cards but not the project page itself, so for the following minute either view could still be showing what was there before. Covered by tests for the listing, the card previews, the timeline, the counts, both services, the cache refresh, and a guard that a project's live prints are untouched by any of it. - **A printer refusing Bambuddy's commands looked healthy, and its queue failed with the wrong advice (#2732, reporter @hennischd)** — Uploads succeeded, the printer echoed the job back, then sat idle for 270 seconds and the job was re-uploaded twice more before failing with a message about SD cards. Temperature changes returned success and did nothing. The connection diagnostic passed every check, and the support bundle said Developer Mode was on. **Root cause.** The printer was rejecting every control command and saying so: HMS `0500-0500-0001-0007`, "MQTT command verification failed" — the firmware's authorization check, which Bambu Lab documents Developer Mode as the way to disable. Nothing in Bambuddy connected that to anything. The error itself was received and then discarded by the frontend, because this code's meaning lives in bits that Bambuddy's short-code form throws away: it collapses to `0500_0007`, matches no catalog entry, and uncatalogued errors without firmware actions are filtered out of the badge count and the error list. Meanwhile the Developer Mode probe reads any response that isn't an explicit refusal as confirmation, and this firmware answers the probe with an empty result while refusing everything else — so Bambuddy inferred a healthy printer from a non-answer, and reported that inference as a passing diagnostic. **Fix.** HMS codes are now looked up by their full identifier before the short form, so this error survives to the screen, shows the four-group code the printer's own display shows, and carries the fix rather than Bambu's "update Studio or Handy" (which does not apply to a print sent from Bambuddy). The error is treated as authoritative about the printer's state: it sets Developer Mode to off regardless of what the probe concluded, which makes the diagnostic and the support bundle report the real situation, and it clears itself when the printer stops reporting it, so enabling Developer Mode and restarting is picked up without restarting Bambuddy. The probe no longer reads an inconclusive answer as confirmation — it reports what it knows, which for this firmware is nothing. A queue item whose command is rejected now fails on the first attempt naming the code and the fix, instead of spending three uploads and fifteen minutes of a farm's upload capacity to arrive at the wrong conclusion; a print that is visibly running is never touched, whatever HMS is lingering. Separately, the log hint suggesting a wrong or mis-cased serial number no longer fires in the moment after a reconnect, when the report counter it reads has just been reset and proves nothing — it cost this reporter a detour through their serial number on a printer whose serial was correct. Translated in all locales. Covered by tests for the code surviving the filter, the display form, the developer-mode override and its self-clearing, the inconclusive probe, first-attempt failure, the running-print guard and the suppressed hint. - **A printer that dropped off MQTT could stay offline indefinitely (#2732, reporter @hennischd)** — In the same bundle, the printer lost its MQTT session to a keep-alive timeout at 02:19 and did not come back until 11:24 — nine hours offline, with the web UI open the whole time. Bambuddy's stale-session detector only covers the other failure: a session that is still connected but has gone quiet. Once the connection is *down*, it returns immediately and the MQTT library's own retry is the only thing still watching; when that stops making progress, nothing notices. Bambuddy now runs a backstop sweep every minute. A printer that had a working session, has been silent for five minutes, and still answers on its MQTT port gets its client rebuilt from scratch with a fresh session — which also drops any command left unacknowledged on the dead one, so it cannot replay into the new session. Printers that are simply switched off are left alone: the port check tells the two apart, so there is no client churn and no nightly log spam for a farm that powers down. The rebuild is rate-limited per printer and never touches a connected one, and the log line names how long the printer was gone and what the last connection error was, so a session that dies repeatedly leaves a trail. Covered by tests for the recovery itself, the grace period, the switched-off case, the retry interval, and a farm sweep continuing past a printer that throws. - **A slot on Generic PLA offered only one K profile, however many the printer held (#2710, reporter @tommyboy180)** — The printer's Flow Dynamics table held nine calibrations, all of them under Generic PLA; Configure AMS Slot offered exactly one, the one already bound to the slot, and after a slot reset it offered none at all. The only way to assign the others was Bambu Studio. **Root cause.** Two independent faults, both triggered by picking a built-in generic preset. First, K profiles are matched to the selected preset by filament ID, but matches on Bambu's generic IDs (`GFL99` for Generic PLA, `GFG99` for Generic PETG, and so on) were deliberately thrown away as too broad — and since the comparison already requires both sides to carry the *same* ID, that exclusion could only ever fire when the chosen preset was itself the generic one, which is precisely the case where the match is right. Second, the matcher read the leading "Generic" in "Generic PLA" as a manufacturer, and so demanded the word "Generic" appear in the K profile's name; no real profile has it, which killed the name-based fallback as well. The one profile that did show up came from an unrelated safety net that always surfaces the slot's active profile, and a reset slot has none — hence the empty list. **Fix.** A K profile whose filament ID equals the selected preset's now matches, generic or not: the printer keeps one calibration table per filament ID, so a slot on Generic PLA offers everything calibrated under Generic PLA, whatever the user named those entries — "Dark Brown", "Marble", "Glow" and the rest now all appear. "Generic" is no longer treated as a brand, so profiles still match by material when a printer reports no filament ID at all. Since no name-and-ID matcher can be perfect against profile names the user invents, the picker now also lists every remaining profile on the printer under **Other K profiles on this printer**, so a profile that exists can always be selected; picking one works exactly as before, because Bambuddy already realigns the slot's filament context to whichever profile is chosen. The same generic-ID rule now applies to the spool form's K profile suggestions, guarded so it can never cross materials — a PETG spool is never offered PLA calibrations — and so that a spool naming its own brand keeps its brand-specific suggestions. Translated in all locales. Covered by tests for the reporter's nine-profile printer, the reset slot, printers that send no filament ID, the other-profiles group and applying a profile from it, and by guards that a brand preset still does not sweep in generic profiles. - **The print-complete photo showed the toolhead still printing, three minutes before the print ended (#2547, reporter @anthonyma94)** — The Discord photo caught the model mid-print with the head over it, instead of the finished print. **Root cause.** Bambuddy fired the photo the moment `layer_num` reached `total_layer_num`. That edge is the moment the printer *starts* its final layer, not the moment it finishes it: on the reporter's H2C it arrived at 92% with two minutes of print still to run, and the last layer took three minutes and seventeen seconds including a filament change. Worse, that trigger latched, which locked out both of the triggers that fire at a genuine end of print — so on printers that never report an end-of-print filament unload (H2C and A1 Mini confirmed) there was no way back to a correct photo. **Fix.** The last-layer trigger is gone. The photo is taken when the print reports itself finished, which is a signal every model sends and which lands after the toolhead has parked. Since the printer's own end G-code drops the plate about 100 mm just before that, Bambuddy now raises it back to just above the last printed layer, takes the photo, then lowers it again — restoring the framing asked for in #1145, #1397 and #1565. The plate move is an absolute Z to a height the toolhead occupied seconds earlier, so it stays inside the travel limits and keeps the nozzle above the part, and it is skipped outright unless Bambuddy can confirm the height belongs to this print: the sliced file is matched by print name, and its layer count is cross-checked against the layer count the printer reported. It is also skipped when another job is queued, when the printer has moved on, and when the new **Restore plate for finish photo** setting is off. Prints whose End G-code Bambuddy injected — SwapMod plate swaps and similar — are detected automatically and keep using a frame from during the print, since their model has left the bed by then (#1867); that frame now also refreshes through the final layer instead of freezing when it began. Prints that record a timelapse normally source the photo from the video's last frame and need no move at all, but when the video has not transferred in time — the usual outcome on P1-series — the live photo that ships in the notification now gets the same plate restore, so it is no longer a shot of an empty-looking lowered bed. The `stg_cur=22` trigger is left in place for any firmware that does emit it, but the bundle survey above still applies — in practice every model reaches the finish-state path. Translated in all locales; wiki updated. Covered by backend tests for the removed trigger, the two surviving ones, the plate move and every condition that suppresses it, and by frontend tests for the new setting. - **A model sliced for PETG printed as PLA, and the print dialog then refused to match PETG (#2712, reporter @kpp39)** — Slicing a MakerWorld model with a PETG profile produced G-code the printer wanted PLA for, and the Filament match step offered no way to correct it. **Root cause.** The list of filaments the slice dialog shows is positional: the first row is the printer's first slot, the second row its second, and so on down to the slicer itself. For a model that already carries slicing information, Bambuddy listed only the slots that print — which is the right answer when you are starting a print and Bambuddy has to match spools in the AMS, and the wrong one here. The reported model declares four filaments and paints with the fourth alone, so the dialog showed a single row; the PETG chosen in it became the *first* slot, and the fourth — the one the model actually prints with — kept the profile baked into the downloaded file. The result was a genuine PLA print, so the Filament match step was right to insist on PLA. **Fix.** When picking profiles for a slice, the dialog now lists every slot the project declares, with the ones this plate doesn't print with shown greyed out as before, so each row lines up with the slot it stands for and a choice made in the fourth row reaches the fourth slot. Starting a print is untouched and still asks only for the spools the job needs. Covered by tests for a source whose only printed slot is the fourth, for the print path keeping the shorter list, and for the chosen profile arriving in the right position. - **A finished slice produced a stream of a dozen "Sliced ..." notifications** — One slice reported itself complete over and over, a notification every second and a half for as long as twenty seconds. **Root cause.** While a slice runs, Bambuddy asks the server how it is getting on every 1.5 seconds — but it never waited for an answer before asking again. Slicing a large project keeps the server busy for seconds at a time, so those questions piled up unanswered, each one still believing the job was running. When the server caught up it answered all of them at once, and every single answer was treated as the moment the slice finished: one notification each, one list refresh each. The bigger the project, the longer the pile and the more notifications. **Fix.** Bambuddy now waits for an answer before asking the next question, so nothing can pile up and a busy server isn't asked to do more work while it is already behind. A job's completion is also recorded once and only once, and a check that was already in flight when the tracker restarts now stops instead of finishing its work — either of which is enough on its own to keep a duplicate off the screen. Covered by tests for a server stalled across many intervals, for two slices finishing where one restarts the tracker, and for the same job being tracked twice in a row still reporting both times. - **A database hiccup during dispatch could leave a queue item stuck and the next print of that file filed under the wrong archive** — When PostgreSQL briefly refused a connection in the middle of dispatching a queue item, the dispatch failed part-way through and left two things behind. **Root cause.** Bambuddy tells itself to expect a print just *before* it sends the print command, because the printer can report the job before the send even returns — but nothing undid that expectation when the command was never sent. The entry does expire after two hours, which is far longer than it takes to react to a failed job by pressing print again: that reprint was folded into the old archive and inherited its filament mapping and plate instead of getting a fresh one. Separately, releasing the row's dispatch lock is best-effort and needed the same database that had just refused, so it gave up after one attempt and the item stayed invisible to the scheduler until a restart. Nothing was ever sent to the printer — the failure happens before the print command — so this cost a stuck item, not a wrong print. **Fix.** An expectation is now withdrawn whenever the print command doesn't go out, which also covers two cases that were silently leaking before: a job cancelled during dispatch, and a print command the printer rejects. The dispatch lock is retried rather than abandoned after one try, and any lock left behind is released on the next quiet moment instead of surviving until a restart. Covered by tests for the withdrawal being an exact inverse of the registration, for a confirmed print keeping its expectation, for two dispatches not disturbing each other, and for the lock recovering from both a brief and a sustained database outage. - **An unusable layer number from a printer could drop its connection (#2702 follow-up)** — Reading the current layer from a status message assumed it would always be a number. Anything else raised an error out of the routine that reads those messages, and nothing above that point catches it, so the connection's listener stopped and the printer looked silent until the staleness check rebuilt it — losing not just the layer number but the print-start and print-finished detection carried in the same message. An unusable value is now ignored, holding the last known layer rather than substituting zero, which the firmware uses to signal a cancellation. This is the same containment applied to the layer *total* in this release, three lines away in the same routine. Covered by tests. - **The layer count stayed empty for a whole print, and First Layer Complete notifications read `1/0` (#2702, reporter @sn8key)** — On a P1S, a job started from Archives or through the Virtual Printer showed no layer information in the Print Status panel, and the Discord **First Layer Complete** notification went out as `1/0` instead of `1/33`. The count usually appeared some minutes into the print, which made it look intermittent, and the reporter noticed it filling in at the exact moment they submitted a bug report. **Root cause.** Bambuddy applies the printer's reported layer total as soon as it arrives, and separately clears that total when it detects a new print starting, so a leftover count from the previous job cannot become the denominator for this job's filament split. Both happen while processing the same status message, in that order — and the message announcing a new print is frequently the same one carrying the new print's layer total, so the value was stored and then immediately discarded. That is not merely late but unrecoverable: Bambu firmware sends only fields whose value has changed, so having published the total once, the printer never mentions it again. It could only come back in a full status refresh, which Bambuddy requests when it connects and when you press Force Refresh, but not during a print. Submitting a bug report happens to trigger one, which is why that appeared to fix it; and an install whose connection drops from time to time recovered by itself within minutes, which is why the fault looked random and why a *stable* connection made it worse rather than better. Everything that shows a layer count reads this single value, so the finish-photo trigger that fires on the last layer and the mid-print filament split were affected the same way. **Fix.** The new-print reset now keeps the total that arrived alongside the starting message, while still discarding anything left over from the previous job. If the starting message carried no total, Bambuddy asks for a full refresh once, and once more if layers are being laid down and there is still no total — by which point the printer certainly knows it. That is at most two extra messages per print, and never a per-layer retry. One incidental fix: an unusable layer total — `null`, or anything that isn't a number — previously raised an error out of the routine that reads status messages. Nothing above that point catches it, so the whole message was thrown away and the connection's listener stopped, leaving the printer to look silent until the staleness check rebuilt the connection. Such a value is now ignored, and the refresh described above then fetches the real total. Covered by tests for the same-message case, the case where the total arrives a message earlier, the previous job's total still being discarded, the one-shot bound, the refresh answer not re-triggering the reset, and the malformed values. - **Support bundles could contain a printer-status file that no tool could open (#2702)** — Every support bundle includes a redacted copy of the printer's last status message, so that a given model and firmware's actual field layout is available when diagnosing a report. For printers whose AMS flow-calibration factor had a particular number of decimal places, that file was not valid JSON. **Root cause.** Redaction ran over the finished text of the file, and one of its patterns recognises Bambu serial numbers by shape — a shape the digits of a decimal number can also take. A calibration value of `0.0199999995529652` came out as `0.[SERIAL]`, which is not a number, so the file failed to parse from that point on. **Fix.** Redaction now walks the structure and rewrites text values only, leaving numbers, true/false and empty values exactly as the printer reported them, so the file always parses. Nothing is redacted less thoroughly than before. Covered by tests using the value from the bundle that exposed this, and for the walk leaving keys, booleans and nested containers alone. - **External-camera timelapses and finish photos came out empty when the live view was open (#2707, reporter @bitbarista)** — On a printer with an external camera, watching the live view while a print ran meant the layer timelapse recorded almost nothing and the finish-photo notification went out with no image attached. The reporter measured zero of 87 layer captures on one print and zero of 105 on another, both watched from start to finish. A USB camera allows exactly one program to hold it open, so every capture taken during a live view failed outright rather than merely degrading. **Root cause.** Bambuddy already knew not to do this for the printer's built-in camera: a snapshot taken while somebody is watching reuses the viewer's frame instead of opening a second connection (#1348, #1271). That rule was never extended to the external-camera paths — and could not have been, because the buffered frame it depends on was only ever published by the built-in paths. The live external stream tracked when frames arrived but never kept one, and the stream hands out frames already wrapped for the browser, so there was nothing for a consumer to reuse. **Fix.** The external stream now publishes each frame as it goes past, and every one-shot consumer — layer timelapse, the finish photo and its fallback, the notification snapshot, Obico polling and the plate check — reuses that frame instead of opening a second handle. If a viewer is attached but no frame has arrived yet, that single attempt is skipped rather than competing, because kicking the viewer off is worse than missing one frame. Two things improve as a side effect: `/camera/snapshot` and the finish-photo fallback chain can now serve an external camera's live frame, where before they found an empty buffer, and the buffer is released when the last viewer of that printer leaves. Covered by tests for the frame plumbing, a buffering failure being unable to break the live stream, and each consumer in all three states — viewer with a frame, viewer without one, and nobody watching. - **A long-running camera stream could eventually stall itself, with nothing in the log to explain it (#2707)** — ffmpeg's error output was only read when something had already gone wrong, which meant that for the entire life of a working stream nobody read it. ffmpeg writes a startup banner, its analysis of the incoming video, and then a progress line at a steady rate, and the operating system only buffers a fixed amount of that before it stops the writer. Once that happened ffmpeg would block trying to write the next line, stop producing frames, and the stream's own timeout would fire — reported as `RTSP read timeout` and a reconnect, with no indication that Bambuddy had starved it. How long it takes to reach that point is unmeasured and clearly long: one stream ran 21 minutes 36 seconds without trouble, so this is a limit that was being ignored rather than a fault anyone has reported. **Fix.** A streaming ffmpeg's error output is now read continuously and the most recent portion kept, so the limit cannot be reached. The kept portion is what gets logged when a stream does fail, which is more useful than before: it holds what ffmpeg said as things went wrong, where reading the buffer on demand returned whatever it had printed first — usually the startup banner, which is then discarded as noise. Credentials are masked on this path through the same single funnel as every other camera log. Covered by tests for continuous draining, the size bound, keeping the newest output, credential masking, and the ownership handover with the shutdown path — reading the same output from two places at once is an error, so only one owner reads it at a time. - **Reopening the camera quickly could leave the new stream invisible to Bambuddy (#2707)** — Closing a camera view and opening it again straight away could leave the newly started stream unregistered, even though it was running and showing frames. The consequences were all indirect, which is what made it hard to spot: Bambuddy believed no viewer was attached, so Obico polling and snapshots would open a second camera connection and fight the live view — precisely what the guards added in #1348 and #1271 exist to prevent; the background cleanup task saw a camera process with no stream attached to it and killed the live stream as an orphan, usually within a minute; and pressing Stop reported that it had stopped nothing while the view was still running. **Root cause.** Each printer's fan-out stream was registered under a key derived from the printer alone, so every successive stream for that printer reused the same key, and the departing stream's cleanup removed whatever was registered under it — including its own replacement. The same cleanup also cleared the printer's most recent camera frame unconditionally, discarding the new stream's frame. It needed the old and new streams to overlap, which the four-second teardown fixed above made easy to hit. **Fix.** Each stream now gets its own registry key, so one stream can only ever clean up after itself — the same approach the external-camera path already uses (#2675) — and the shared per-printer frame is only released when no stream for that printer is left running. Covered by tests for both halves, including one that drives the real cleanup path with a second stream already registered. - **Closing the camera held the printer's camera connection for four more seconds, then logged an error that wasn't true (#2707)** — Every time a camera view closed, the log recorded `ffmpeg didn't terminate gracefully, killing` and then `ffmpeg did not exit within 2.0s of SIGKILL; abandoning wait`. Both waits expired every single time, so each close cost a fixed four seconds — and because Bambu firmware allows exactly one camera connection, that was four seconds in which nothing else could use the camera: reopening the view, a snapshot, Obico, or the diagnostic. **Root cause.** ffmpeg is started with its output and error streams as pipes, and the shutdown path had stopped reading them. A process whose output pipe is full blocks mid-write, and ffmpeg's shutdown signal only sets a flag that its main loop checks on the next pass, so the polite request could never be acted on and the grace period was dead time. The forced kill did work — but Python cannot report an exit while a pipe is still unread, so the second wait expired too and Bambuddy concluded the process was stuck when it had already gone. Measured at 4.00s per close before, ~0.15s after. **Fix.** Both pipes are now drained while the process is being stopped, which makes the polite shutdown effective and the exit observable. The forced-kill path and its time limit remain as backstops, so a genuinely wedged process still can't hang a stream, a Stop request, or the cleanup task. This also corrects the conclusion recorded for #2580: that 12-hour hang was the unbounded form of this same self-inflicted stall rather than a stuck ffmpeg, so bounding the wait had capped the symptom without removing the cause. Covered by tests that drive a real subprocess — the fault lives in Python's pipe bookkeeping, so a stand-in object would pass against the broken code — including one that verifies a process ignoring the polite signal still has its forced exit observed rather than abandoned. - **A crash or restart mid-print left layer-timelapse files behind forever (#2709, reporter @bitbarista)** — `timelapse_frames/` grew slowly and never shrank on its own. After several routine restarts during testing, it had accumulated 38MB: three abandoned frame directories and two 48-byte `.mp4` files from stitches that never finished writing. **Root cause.** Which timelapse session is active lives only in memory. A restart for any reason — a redeploy, a crash, a power loss — loses that bookkeeping instantly, but the frames already written for that session, and any partially-stitched output file, stay on disk with nothing left that knows they exist. `on_print_complete`'s cleanup never runs for them, because nothing calls it: the session it would clean up no longer has an entry to be found by. A restart-recovered print doesn't get a replacement session either (`_maybe_start_layer_timelapse` only fires on a fresh `PRINT_START` event, #1353), so an orphaned directory can never be resumed or claimed by anything — it just sits there. **Fix.** A one-time sweep on startup removes any `timelapse_frames//` entry that doesn't match a session that printer is currently recording or stitching, and that is old enough not to be a startup race (five minutes). Only this feature's own artifacts are swept — a frames directory or a `timelapse_.mp4` — so anything else that ends up under that folder is left alone rather than deleted for being old. Verified against the real 38MB of leftovers: startup logged each of the five removed by name, and the directory dropped to 8KB. Covered by tests for the orphaned-directory and stray-output-file cases, sparing a genuinely active session, sparing one that is mid-stitch (the window where the session has already been handed to ffmpeg and is no longer listed as active), sparing anything too recent to be sure about, leaving unrelated files alone, not counting a removal that failed as a success, and two defensive cases (no `timelapse_frames/` directory yet, an unrelated non-numeric entry under it). - **Two snapshots taken at the same moment opened two competing camera connections (#2705, reporter @gzimbric)** — Bambu firmware allows exactly one camera connection at a time. Bambuddy already knew this: a snapshot taken while somebody is watching the live view reuses the viewer's frame instead of opening a second socket. What nothing covered was two *snapshots* overlapping with no viewer attached at all — an Obico poll and a printer-wall refresh landing 200 ms apart, each correctly concluding it wasn't competing with a viewer, and then colliding with each other. On the reporter's P2S this knocked over the live stream that was feeding the camera wall, which was then reaped for having received no frames for 58 seconds. Eight paths take one-shot frames independently — Obico polling, `/camera/snapshot`, the finish-photo capture and its disk-writing sibling, plate detection, the camera connection test, and the diagnostic — so any pair of them could overlap, and a shorter Obico interval widened the window. **Fix.** Simultaneous captures for the same printer now share one connection: the first opens it, everyone arriving while it is in flight gets the same frame. Every consumer here wants "a recent frame" rather than a frame stamped at its own microsecond, so identical bytes are the right answer. This shares captures, it does not cache them — a request arriving after the previous capture finished still takes a fresh frame, because plate detection and the finish photo judge a running print from these images and a stale frame there is worse than a slow one. Each caller keeps its own deadline (they range from 10 to 30 seconds) rather than inheriting whichever one happened to open the connection, giving up alone leaves the capture running for whoever else is waiting on it, and a capture that fails doesn't hand its failure to callers that never got an attempt of their own — they retry, which by then competes with nothing. One visible consequence: when the **Diagnose** tool shares a capture this way its frame-capture stage is labelled `coalesced_capture`, because the pass is real but the timing shown is mostly time spent waiting, and a diagnostic must not report on a connection it never opened. Wiki updated. Covered by tests for the reported collision, the five-callers-one-connection case the reporter verified on live hardware, per-printer isolation, staying coalescing rather than becoming a cache, registry cleanup, a failed capture not poisoning its followers, bounded retry, a follower abandoning its wait without sabotaging the capture, and cancellation from either side. - **The same one-shot-capture collision could happen on external cameras too, with no viewer attached (#2707 follow-up, reporter @bitbarista)** — #2705 fixed simultaneous captures colliding on the built-in camera path, keyed by printer IP through `capture_camera_frame_bytes()`. External cameras reach the same kind of collision through a different function — `external_camera.capture_frame()` — that #2705 didn't touch, and a V4L2 USB device allows exactly one open handle just like Bambu's own RTSP limit. Nothing coalesced two one-shot capturers here either: Obico polling, the in-print frame bank, the finish-photo moment, plate detection and the notification snapshot could each open their own connection to the same USB camera and collide, with `is_stream_active()` unable to help since that guard only stops a capturer from competing with an *attached viewer*, not with another capturer. **Fix.** The same shape of fix as #2705, applied to `capture_frame()`: concurrent callers for the same camera (URL, type, and — since #1177's snapshot override routes to a different endpoint entirely — snapshot URL) share one capture rather than opening a second connection. Coalesces, does not cache, so a call after the previous one finishes always captures fresh. Each caller keeps its own timeout, giving up leaves the capture running for whoever else is waiting, and a capture that fails doesn't hand its failure to a caller that never got a turn of its own. One visible consequence, mirroring the label #2705 added to the built-in **Diagnose** tool: pressing **Test** on an external camera while a capture is already running now says the frame was shared with it, because the result is real but the test did not open a connection of its own — and forcing one would be the very second handle this change exists to prevent. Covered by tests mirroring #2705's: the reported-shape collision, five callers sharing one connection, per-camera and per-snapshot-URL isolation, staying coalescing rather than becoming a cache, registry cleanup, a failed capture not poisoning its followers, bounded retry, a follower abandoning its wait without sabotaging the capture, and cancellation from either side — plus, for this path specifically, that an unexpected error is reported as a failed capture rather than raised at every waiting caller at once, and that the shared-capture log lines redact credentials, since an RTSP camera URL routinely carries `user:pass@` where the built-in path's key is only an IP address. - **Auto-matched filament showed a green tick when the colour was plainly wrong (#2687, reporter @pchulpjoost)** — The Filament Mapping panel reported a slot as matched, with the header reading **(Ready)**, while the swatch beside it showed the slice wanted dark red and the tray it had picked held Dark Green. Manually selecting that very same tray from the dropdown correctly reported the colour mismatch, which is what made the disagreement so visible. **Root cause.** Auto-match ranks candidate trays by filament preset ID (`tray_info_idx`) first, and when exactly one loaded tray carried the preset the slice asked for, that tray was accepted as a *definitive* match on the assumption "same preset means same spool, so the colour must agree too". The preset ID names the **variant**, not the spool — `GFA00` is PLA Basic, `GFA01` PLA Matte, `GFA17` PLA Translucent, in every colour Bambu sells it. So a user with one Matte spool loaded matched every Matte requirement regardless of colour, and the colour comparison was never reached. This is why the report came in for PLA Matte in particular: generic PLA Basic is usually loaded several times over, which sent the match down a different path that did compare colours correctly. **Fix.** The colour verdict is now taken from the tray that was actually selected, never from which rule selected it, and the automatic and manual paths share one comparison so they cannot drift apart again. The preset still decides *selection*, because the Basic/Matte/Silk distinction matters ([#2650](https://github.com/maziggy/bambuddy/issues/2650)) — a wrong-coloured tray of the right variant is still chosen, but it is now reported as an amber **Color mismatch** instead of a green tick, and you can print anyway or pick another slot. A near-enough shade still counts as a match, and a 3MF that specifies no colour for a slot is satisfied by any colour rather than being flagged. Dispatch behaviour is unchanged: **Force color match** already required an exact colour before sending a job, so nothing was ever printed in the wrong colour because of this — the panel was simply telling you it was fine when it wasn't. Frontend-only. Wiki updated. Covered by tests for the unique-preset wrong-colour case, agreement between the auto and manual verdicts, the near-shade and colourless-requirement cases, and the multi-preset path that already worked. - **P1-series archives kept the worse finish photo when the timelapse arrived late (#2704 follow-up)** — When a print records a timelapse, Bambuddy prefers the video's last frame as the finish photo: the firmware stops recording after the toolhead parks but before the end G-code drops the bed, so it frames the finished print properly, where a live camera grab at that moment catches an already-lowered plate. Bambuddy waited 60 seconds for the video and then gave up, because the print-complete notification is waiting on that photo and holding a notification for minutes is worse than sending it with the live grab. On P1-series printers the video usually arrives later than that — they write MJPEG AVI instead of H.264 MP4 and serve it slowly, so across the support bundles their median was 33 seconds but the 90th percentile was 167 and the slowest observed was 546; every other model finished inside 26 seconds. The result was that the printers most in need of the better photo were the ones that never got it. **Fix.** The notification still goes out on the same 60-second bound with the live grab, so nothing gets slower. If the video was still on its way when that bound expired, Bambuddy now keeps waiting in the background and adds the extracted frame to the archive when it lands, at the front of the photo list so opening the gallery shows it first. The live grab is kept rather than replaced — the notification that already went out links to that exact file, and removing it would leave a broken image in Discord or Telegram. Covered by tests for the ordering, the longer budget, idempotency and the cases where the video never arrives. - **Timelapses that never got attached, and a Scan button that could not find them (#2704)** — Timelapse was on for the print, the video never arrived in the archive, and pressing **Scan for Timelapse** afterwards turned up nothing. Measured across 247 support bundles, this was not rare: of 457 automatic scans only 262 ever attached a video. **Root cause, part one.** The scan looked four times, at 5, 10, 20 and 30 seconds, then stopped. The printer writes the video only after the print ends and a long print makes a large file, so it often arrived after the last look — the attempt that found the video was the first one 272 times and then 17 / 13 / 13, a flat tail against the cutoff rather than a decaying one. What ran after those four attempts was a fallback that searched for the print's name inside the video filename; Bambu firmware only ever writes `video_`, so in 247 bundles it fired 159 times and matched exactly zero. **Root cause, part two.** The manual Scan button had no such snapshot to work from and matched by filename timestamp, by FTP modification time, or by there being exactly one video on the printer — all of which read a clock the printer cannot set, because a printer in LAN Only mode never reaches Bambu's time server. The reporter's P1S was six and a half days out, which defeats every one of those. **Fix.** The automatic scan now polls for several minutes instead of giving up after about a minute, and the name-match fallback is gone. The list of videos present when the print started is saved with the archive, so the comparison survives a Bambuddy restart mid-print and the manual Scan button can use it too — same clock-independent comparison, no timestamps anywhere. When a previous print's video lands late and two files look new, the one already attached to another archive is ruled out by name rather than by picking whichever the printer listed first, which could attach the wrong video. **Bambuddy now deletes a timelapse from the printer once it has been archived**, which keeps the printer's folder down to unclaimed videos and stops P1-series cards filling up with AVIs; your copy is in the archive, where you can watch, edit, download or remove it. That delete only happens after the transfer has been checked against the size the printer reported — which also fixes a silent truncation: an FTPS transfer that ended early produced a partial video that was attached as though it were complete. Because the first look happens seconds after the print ends — while the printer may still be writing the video — the file is also re-checked afterwards and only accepted once it has stopped growing, so a partial video is never mistaken for a finished one and the printer's copy is never removed on the strength of one. Wiki updated. Covered by tests for candidate selection, the download check gating the delete, the poll bounds, baseline persistence and the manual scan. - **A printer that refuses Bambuddy's access code now says so, instead of reconnecting silently forever (#2698, reporter @djepsylon)** — A printer whose access code or serial was wrong produced no explanation anywhere: the connection attempt was refused, and the only trace was a warning every 30 seconds reading `MQTT disconnected: rc=Unspecified error` — the same line you get from a printer that is simply switched off. The failure branch of the MQTT connect callback set "not connected" and discarded the reason code the printer had just sent, so the one piece of evidence that would have named the cause never reached the log, the support bundle, or the UI. In the report behind this fix, one of three printers had been in that loop for the entire capture and nothing said why. **Fix.** A refused connection is now logged with the printer's own reason ("Not authorized", "Bad user name or password") and, for those two, the remedy — the access code is regenerated every time LAN Only or Developer Mode is toggled, so it has to be re-read from the printer's screen. The reason is kept on the connection, so the **Connection Diagnostic**'s *Printer credentials* check now states plainly that the printer refused the credentials when that is what happened, and falls back to hedged wording when all Bambuddy knows is that there is no session — previously it asserted "the access code is most likely wrong" even for a printer that was merely rebooting or already at its connection limit. The same reason is returned by the pre-add connection test. The access code itself is never written to the log. Translated in all locales; wiki updated. Covered by tests for both refusal codes, the clearing of the reason on a successful reconnect, the diagnostic's reason plumbing, and the two UI variants. - **Camera credentials could reach the log and the support bundle, and a camera test could pass without opening a connection (#2721, contributor @bitbarista)** — Review follow-ups on the external-camera capture coalescing. That logic was transplanted from the built-in camera path, which keys on a printer IP and so has nothing to hide in a log line; these keys are camera URLs, and an RTSP URL routinely embeds `user:pass@`. Five new log lines printed the password, one of them at warning level, where it reaches support bundles. All five now redact **before** truncating — slicing first can cut the URL short of the `@` the pattern anchors on and leave the password intact, which is why every other URL log in the module already does it in that order. **Also fixed:** an unexpected error inside one capture reached every caller waiting on the shared result at once, so one caller's failure became N and none of them retried; the shared wrapper now contains it and reports a failed capture. And **Test connection** returned success for a frame it had been handed from someone else's in-flight capture — the one answer a connection test must not give silently. It still shares rather than forcing its own capture, because forcing one would open the second handle to a single-reader device that this whole mechanism exists to prevent; it now says the frame was shared with a capture already running. - **The orphaned-timelapse sweep could delete a timelapse while it was still being stitched (#2722, contributor @bitbarista)** — The sweep's own docstring claimed its age margin made it safe to run mid-stitch. It was not. A session is dropped from the active list before its frames are handed to ffmpeg, so for the length of a stitch the directory matches no active session, and its mtime is the last layer's frame write — on a tall print's final layer, easily older than the margin. The default margin and the stitch timeout were both 300 seconds, so the two were tied with no headroom at all, and a sweep landing in that window deleted ffmpeg's input from under it. An in-progress stitch is now marked explicitly, and the marker is cleared in a `finally` so a failed stitch cannot leak it and leave that printer's files permanently un-sweepable. **Also fixed:** the sweep deleted any file under its working directory past the margin rather than only the `timelapse_.mp4` shape this feature creates — nothing else writes there today, but age alone is not a reason to delete a file this feature did not create. And a removal that failed on a read-only mount or a permissions problem was counted and logged as a success, which matters because that log is the only evidence an operator has of what was deleted. - **Finish photos came out upside-down, or rotated twice, when a camera rotation was set (#2723, contributor @bitbarista)** — `camera_rotation` was only ever wired into the print-start photo and the in-print frame bank; the finish-photo pipeline saved its frames straight to disk unrotated. Fixing that surfaced a second fault: rotating the frame where it was consumed rotated one of its two sources twice, because the in-print bank's frames arrive already rotated while live grabs are raw, and the consumer cannot tell them apart. On firmware that never emits `stg_cur=22` — the path that bank exists to serve — a 180 degree rotation cancelled itself out and the photo was upside-down again, with 90 and 270 landing 180 out. Rotation now happens where each frame is captured, so every cached frame carries exactly one whatever produced it. **Also fixed:** two finish-photo sources were still writing unrotated files — the built-in camera's own capture, and the still extracted from a printer-recorded timelapse, which is the *preferred* source for a built-in camera print, so whether a photo came out the right way up depended on which source happened to win. Layer-timelapse frames are rotated too. Note that the archived video is the printer's own file and is not re-encoded, so it still plays at the camera's native orientation. A failed rotate leaves the unrotated file rather than losing a delivered photo. - **Ukrainian was listed above Russian in the language picker** — Locales appear in the picker in the order they were added, but `uk` had been inserted ahead of `ru` in the import block, the resources map and the list the picker renders. Moved to the end of all three; the alphabetically sorted supported-language list already had it in the right place. Frontend-only, with no behaviour change beyond the row order. - **Setting Spoolman options over the API with a true/false value returned a server error** — `PUT /settings/spoolman` accepts a free-form body, and sending the natural JSON form for a switch — `{"spoolman_enabled": true}` rather than `{"spoolman_enabled": "true"}` — came back as a 500 with nothing useful in it. The shipped UI always sends strings, so this only affected people driving Bambuddy from a script or a Home Assistant `rest_command`, which is exactly where a real boolean is the obvious thing to send. **Root cause.** Settings are stored as text and every reader compares them as text, but the submitted value went in untouched. Deciding whether Spoolman had just been switched on called a string operation on it, which a boolean does not have; and the raw boolean was also written straight to a text column, which SQLite quietly turns into 1/0 while PostgreSQL refuses it outright — so the stored result depended on which database the install used. **Fix.** Boolean-ish settings are now converted to a canonical `true`/`false` on the way in, accepting real booleans, `1`/`0`, and the usual spellings (`True`, `yes`, `on`) case-insensitively, since this is a documented API that scripts talk to. A value with no sensible reading, such as `"banana"`, now returns a 400 naming the field instead of being stored as-is and silently treated as off. Two details are preserved deliberately: a blank value still means "use the default" for the two options that default to on, and reading a stored value stays as strict as it has always been elsewhere in the codebase, so no existing row changes meaning. Text options are checked too, so a JSON object can no longer be stored as its own printed form. One incidental improvement: a value stored as `True` by an earlier API call showed as off in the UI, which compares case-sensitively, while the backend treated it as on — canonical storage removes that disagreement. Covered by tests across the accepted spellings, the rejected values, the blank-means-default behaviour, and the read path. ### Security - **Patched two build-time frontend dependencies flagged by `npm audit` (GHSA-r28c-9q8g-f849, GHSA-mh99-v99m-4gvg)** — `postcss` 8.5.15 → 8.5.23 fixes a path traversal in its source-map auto-loader (`sourceMappingURL`) that could disclose arbitrary `.map` files, and `brace-expansion` (pulled in transitively by `eslint` via `minimatch`) is bumped through the existing `overrides` block (`^5.0.7` → `^5.0.8`) for a denial-of-service via unbounded expansion. Both are build/lint-time tooling only — neither is part of the shipped app, so no running Bambuddy install was exposed. `postcss` moved within its existing range; `brace-expansion` needed the pin because `npm audit fix` can't lift `eslint` to the patched transitive on its own. - **Pinned `react-router` to its most-patched 7.x (7.18.1) and documented the one remaining, unreachable advisory (GHSA-qwww-vcr4-c8h2)** — Staying current on the 7.x line matters: 7.18.1 clears 14 advisories that older 7.x releases carry, several reachable in a browser SPA (open-redirect XSS in ``/`useNavigate`, route-matching DoS). The single advisory that still flags 7.18.1 — a CSRF bypass — applies only to React Router's **RSC mode**, which requires the server runtime (`@react-router/server`, not installed); Bambuddy is a Vite SPA using `BrowserRouter`, so the vulnerable path is unreachable. There is no non-major fix (the patch landed only in the 8.3.0 major, and `react-router-dom` has no 8.x — adopting it would mean migrating every import to `react-router` plus a React peer bump), so `react-router`/`react-router-dom` are pinned to 7.18.1 and the finding is carried as a documented, fail-closed exception in the CI audit gate: a *different* react-router advisory still fails CI, and the exemption is dropped automatically the moment a non-major fix ships. `npm audit fix --force` is deliberately avoided — its suggested "fix" is a downgrade to 7.11.0, which reintroduces those 14 advisories. ## [1.2.5.1] - 2026-07-27 ### Fixed - **The spool PA-Profil (Pressure Advance) picker only ever offered the 0.4mm K-profile, hiding nozzle-specific profiles for the same filament on multi-nozzle printers (#2618)** — When a printer had two K-profiles for one filament differing only in nozzle size (e.g. PAHT-CF at 0.4mm K=0.042 and 0.6mm K=0.028), the **Edit Spool → PA-Profil** tab (and the SpoolBuddy write-tag page, which shares the picker) showed only the 0.4mm entry ("1 match, K=0.042"), regardless of the nozzle actually installed. **Root cause.** Both surfaces fetched a printer's calibrations with `getKProfiles(printer.id)`, which defaults the nozzle filter to `0.4` — and the printer/MQTT layer filters strictly by that diameter, so the 0.6mm profile was never retrieved. (The AMS-Slot config dialog was already fixed for this in #1899; these two pickers were not.) **Fix.** The picker now queries every nozzle the printer reports installed (`0.4`, `0.6`, …) and merges the results, falling back to `0.4` only when the printer hasn't reported its nozzle hardware. Each profile row now also shows a nozzle-diameter badge so two identically-named profiles are distinguishable. Frontend-only. Covered by tests for the nozzle enumeration and the two-profile rendering. - **Print-archive backups to a Gitea or Forgejo instance hosted under a URL path prefix could not be configured — the repository URL failed to parse (#2642, reporter @M1ndHunteR)** — Self-hosted Gitea/Forgejo is often served under a subpath (`ROOT_URL` like `https://host/gitea`), so repositories live at `https://host/gitea/owner/repo` rather than at the host root. **Root cause.** The Gitea backend (shared by Forgejo) assumed the repo sat directly under the host: URL parsing required exactly two path segments after the hostname, so a subpath URL's three segments (`gitea/owner/repo`) matched nothing and raised "Cannot parse repository URL". Even had it parsed, the API base was derived from scheme+host only, yielding `https://host/api/v1` instead of `https://host/gitea/api/v1`, so every API call would have 404'd. **Fix.** The Gitea/Forgejo backend now treats the final two path segments as `owner`/`repo` and keeps any leading segments as a base-path prefix, deriving the API base as `{scheme}://{host}{prefix}/api/v1`. Root-hosted instances are unaffected (empty prefix). GitHub/GitLab are untouched. Covered by parse and API-base tests for both providers. - **The Print Queue's History tab showed a count of all prints but only ever displayed the first 50, with no way to reach the rest (#2682, reporter @pchulpjoost)** — The History header read e.g. `History (311 items)`, but only 50 rows rendered and there was no "load more" control, so 261 finished prints were unreachable. **Root cause.** The full history is already loaded client-side (the queue endpoint has no limit) and sorted correctly — the header counts the whole list — but the row builder hard-sliced it to `items.slice(0, 50)`, a fixed cap with no accompanying control. Nothing was missing server-side; it simply wasn't drawn. **Fix.** History now paginates: it draws the 50 most-recent prints and, when there are more, shows a **Show more** button (with a `Showing X of Y` count) that loads the next 50, repeating until the whole history is on screen. The page size resets to the first page only when you re-sort or change the location filter — deliberately not on the periodic queue poll, so an expanded view doesn't collapse mid-scroll. Frontend-only; batch grouping and per-row actions are unchanged. Covered by a test asserting the 50-row cap, the `Showing 50 of 60` count, and that Show more reveals the remainder. Wiki updated. - **LDAP Distinguished Names weren't redacted from the support bundle / bug report (#2681, reporter @MaxBareiss)** — With LDAP auth in use, the debug log carried lines like `LDAP authentication successful for user: … (DN: CN=Joe Schmoe,CN=Users,DC=ad,DC=example,DC=com, …)`. A DN's leaf `CN` is the user's real name — PII on par with the email address Bambuddy already redacts — and it passed straight through into an uploaded support bundle. **Fix.** The log sanitizer (used by both the support bundle and the in-app bug report) now redacts LDAP DNs to `[DN]` wherever they appear — the auth line, ldap3 exception strings, and group DNs alike — matching a run of `attr=value` RDN components (`CN/OU/DC/UID/…`) so ordinary `key=value` log text isn't affected. As primary hygiene the LDAP service also no longer logs the raw DN on successful auth (the username plus group count is enough). Covered by tests, including the exact reported line and non-DN `key=value` lines that must be left intact. Redaction list on the Bug Report wiki page updated. - **An external USB camera could stay locked (LED stuck on) after closing the live view, blocking reopen (#2675, reporter @bitbarista)** — Closing an external USB (V4L2) camera's live view abruptly — tab/popup closed, or a dropped connection — could leave the backend's `ffmpeg` process running and holding `/dev/videoN` open. The camera LED stayed lit and the next attempt to open the view (or click Test) failed or took 10-30+ seconds while the new `ffmpeg` fought for exclusive device access. **Root cause.** This is the same class of leak as #776 (fixed for the built-in RTSP path), but the external/USB path was never wired into that fix. #776 added the `_active_streams` / `_disconnect_events` / spawned-PID registries so both the `/camera/stop` endpoint and the periodic orphan janitor could find and kill leaked ffmpeg — but external streams registered into none of them, so for USB cameras both were structurally blind: `/camera/stop` returned `{"stopped": 0}` even while a stream was genuinely running, and the janitor's `/proc` net matched only `rtsp(s)://bblp:` cmdlines, never a USB `ffmpeg`. Cleanup ran only via the stream generator's own `finally`, which an abrupt disconnect can skip. **Fix.** External USB (and external-RTSP) streams now register their `ffmpeg` process into the same registries the built-in path uses, so `/camera/stop` terminates them promptly (now `{"stopped": 1}`) and the janitor reaps any that leak within its cleanup interval. The `/proc` safety-net scan also now recognises USB (`-f v4l2`) `ffmpeg`, so orphans surviving an app restart are caught too; a leaked process that hangs on a still-locked device (rather than exiting) is registered before the startup probe so it can still be killed. Covered by tests: the stream hands its process to the registry, the stop endpoint and janitor both reap a registered external stream, and the `/proc` scan matches `v4l2` while ignoring unrelated `ffmpeg`. Thanks to @bitbarista for the precise diagnosis. (Reported alongside a working fix; implemented here.) - **A broken slicer sidecar silently produced tiny corrupt files that were queued and printed anyway, and a reverse-proxy 413 wasn't self-explanatory (#2671, reporter @Austinzveare)** — With the slicer-API sidecar behind a reverse proxy, slicing produced ~28-byte files that "did nothing" (and could still be sent to the printer), while a separate proxy attempt failed with a bare **413 Request Entity Too Large** that the recommended nginx fix didn't seem to resolve. **Root cause.** Bambuddy's slice client only validated the sidecar's HTTP *status*, not its body. When the sidecar — or a proxy in front of it — returned `200 OK` with a body that wasn't a real 3MF (a stock/misconfigured sidecar, a proxy error page, a truncated response, or an OrcaSlicer/Bambu Studio CLI crash that emitted no output), Bambuddy wrote that tiny blob straight to a `.gcode.3mf`, stored it as a valid sliced file (the 3MF-parse failure was swallowed as merely "no thumbnail"), and let it be queued and FTP'd to the printer. Separately, a genuine 413 comes from the reverse proxy in front of the sidecar rejecting the multi-MB upload (model + profiles), not from the slicer — so raising the body limit on the wrong proxy layer had no effect. **Fix.** The slice client now validates the sidecar's output: when a 3MF export was requested, the response body must be a real ZIP (3MF container) or the job fails loudly with an actionable message ("…the body is not a valid 3MF (N bytes) — check the sidecar URL and any proxy in front of it") instead of persisting a corrupt file. A 413 now yields a targeted message naming the fix — raise `client_max_body_size` (or equivalent) on the proxy directly in front of the sidecar. Covered by tests: a 200 with a non-3MF body raises a server error (both the profile and embedded-settings paths), a 413 surfaces the reverse-proxy guidance, a valid 3MF still slices, and raw-gcode preview output is not zip-validated. Wiki troubleshooting updated with both scenarios. - **File Manager "sort by recent activity" didn't match `ls -t`, and there was no way to see a file's modified date (#2680 / #1770 follow-up, reporter @Kingbuzz0)** — For external (mapped/NAS) folders the folder tree's activity sort and the file pane's date sort put things in a seemingly random order — some entries roughly right, most not — instead of the real newest-first order shown by `ls -t` or Windows Explorer. **Root cause.** Nothing captured the files' actual on-disk modification time. The sort keyed off Bambuddy's own database `updated_at`/`created_at` timestamps, which for a bulk external scan are all the same instant (the scan time), so a whole block of files tied and sorted arbitrarily; only the few rows Bambuddy had later touched individually looked "partially correct." The folder tree also only bubbled up *immediate* child-file activity, so a file added deep in a subtree never lifted its parent folders. **Fix.** External scans now record each file's and each directory's real filesystem mtime (`os.stat().st_mtime`), refreshing it on every re-scan so a file edited over the mount re-sorts correctly. The folder tree's "recent activity" is now a **recursive** newest-descendant roll-up — a freshly-added file anywhere inside a folder lifts every ancestor — and both the tree sort and the file pane's date sort use the real mtime (falling back to `created_at` for managed uploads that have none). A new toolbar toggle shows/hides each item's **last-modified date** in the right-hand pane (grid and list views). Existing external folders backfill their mtimes on the next scan. Covered by tests: scan captures real file/folder mtimes, a re-scan refreshes a changed file, and a deep file bubbles its subtree's root ahead of a sibling with only a middle-aged file. - **An AMS-HT slot kept showing the removed filament and never cleared (#2670, reporter @needo37)** — After the #2594 fix, every empty-slot clearing path skipped AMS-HT units, so once a spool was removed the HT slot on the printer card stayed stuck on the old filament (Bambu Studio correctly showed it as Empty). The root cause was the HT's presence signal: firmware reports it as a single consecutive bit in `tray_exist_bits` at `16 + (ams_id − 128)` (HT-A = bit 16, HT-B = bit 17, …), not the regular `ams_id × 4` position — so the bitmask cleanup skipped the HT entirely, and the HT's `state` field is firmware-variant and can't be used instead. Confirmed against a live H2D capture (loaded HT reports the bit set, empty reports it clear) and cross-checked with the OrcaSlicer reference. **Fix.** The bitmask cleanup now understands the HT's real bit position and clears an empty HT slot the same way it clears a regular one, using firmware's own authoritative presence bit — so a loaded HT is never wrongly cleared (its bit stays set, keeping the #2594 fix intact). The AMS change detection now hashes the merged state, so a removal signalled only by the bitmask still unbinds the slot's spool assignment; and the websocket status now carries the presence bit so the card renders "Empty" (not "?") consistently. Verified for both single- and dual-HT setups. - **The print dialog clipped the per-filament gram usage when the material name was long, especially on mobile (#2669, reporter @apizz)** — In the Print dialog's Filament Mapping, each required filament shows its name and the grams the job needs, e.g. `Bambu PLA Basic (281.2g)`. The name and the gram figure lived in a single fixed-width column that truncated as one unit, so a long name (e.g. `Polymaker PLA Matte`) pushed the `(…g)` off the end and cut it off — partially on a wide screen, entirely in mobile portrait. The gram usage is the more important number here (it's what tells you whether a spool has enough left), so hiding it was the wrong thing to drop. **Fix.** The gram usage is now pinned and never shrinks or truncates; only the material name truncates (with the full name on hover), so the `(…g)` stays fully visible at every width. Applied to both the Specific-Printer and "Any [model]" mapping panels. Frontend-only, no behaviour change beyond layout. Covered by a test asserting the gram figure renders in its own non-truncating element separate from the truncating name. - **A printer's nozzle size got overwritten to the wrong value (often 0.8mm), then blocked prints as a nozzle mismatch (#2663, reporter @huykent)** — A1 printers with a 0.4mm nozzle intermittently showed **0.8mm** (or no size at all) on the dashboard, and since 1.2.5 that wrong value made the nozzle-mismatch guard (#1899) refuse to dispatch the job — "File sliced for a 0.4mm nozzle, but the printer has 0.8mm installed." It was intermittent and could flip *after* a job was sent. **Root cause.** Bambuddy fetches K-profiles by probing every nozzle size in turn — it sends an `extrusion_cali_get` request for 0.2, 0.4, 0.6 **and** 0.8mm. The printer's response to each echoes the *requested* nozzle diameter at the top level, and the MQTT handler passed every `print` message — including these K-profile responses — through `_update_state`, which treats a top-level `nozzle_diameter` as the installed hardware. So the last size probed (0.8) clobbered the real nozzle size in memory; a later genuine status push would correct it, and the next K-profile fetch would break it again, which is why it flickered and "changed after the job was sent." The raw MQTT status always reported the correct 0.4 — only the derived hardware-nozzle field was corrupted. **Fix.** `extrusion_cali_get` responses are now handled *only* by the K-profile parser and no longer fed to `_update_state`, so they can't touch the nozzle hardware state — mirroring the existing guard that already stops `get_accessories` responses from doing the same thing.The installed nozzle size now comes solely from the printer's real status push, where it was always correct. No configuration or migration needed: the value lives in memory and self-corrects on the next status push after updating. Covered by tests: a 0.8mm K-profile response leaves a 0.4mm nozzle untouched, the response's profiles are still parsed into `state.kprofiles`, and a genuine status push still sets (and corrects) the nozzle. - **The print queue couldn't be reordered on a phone, and the reorder controls were invisible in portrait (#2667, reporter @aporlebeke)** — On mobile there was no way to reorder the queue: in portrait the reorder controls simply weren't visible, and even in landscape (where the desktop drag handle appears) touch-dragging didn't move anything. **Root cause.** The drag grip and selection checkbox on every pending row are `hidden sm:flex`, so below the 640px breakpoint (phone portrait) they disappear entirely — there's no affordance to grab. Above it (landscape phone/tablet) the grip shows, but it carried `touch-action: manipulation` and the only drag sensor is dnd-kit's `PointerSensor` with an 8px activation distance, so on touch the browser claimed the vertical gesture as a scroll before the drag ever started. The whole reorder mechanism was effectively mouse-only. **Fix.** Pending rows now get tap-friendly **up/down arrow buttons** on mobile (the "arrow select" the reporter asked for), shown below `sm` where the drag handle is hidden. They move a row one step among its siblings — standalone items, whole batches, and items within a batch, in both the flat and per-printer layouts — and persist through the same `POST /queue/reorder` path as drag, so arrows and drag agree. Arrows appear only in the manual "position" sort (with shortest-job-first off), where a position actually has meaning, and are gated on the same `queue:reorder` permission; the up arrow on the first row and the down arrow on the last are shown disabled. Separately, the desktop drag handle's `touch-action` is now `none`, so mouse-style drag also works on touch (landscape phones, tablets). Reuses the existing `queue.moveUp` / `queue.moveDown` translations (already present in all locales). Covered by tests: the controls render for pending items, moving the first item down persists the swapped order, and the boundary arrows are disabled. - **3D Preview plate thumbnails were broken (401) in File Manager when login was enabled (#2661, reporter @fbordonaro)** — Opening a multi-plate 3MF via **File Manager → 3D Preview** showed broken-image icons for every plate thumbnail, and the network tab showed `GET /api/v1/library/files//plate-thumbnail/` returning **401 "Valid camera stream token required."** The Slice dialog displayed the same file's thumbnails correctly, which is what made it look inconsistent. **Root cause.** The plate-thumbnail endpoints (both archive and library) are gated behind a **camera stream token** passed as a `?token=` query param, because an `` tag can't send an `Authorization: Bearer` header. Every place that renders these thumbnails is supposed to append the token via the `withStreamToken()` helper — `PlatePickerModal` (the Slice dialog's multi-plate picker) and the Print modal's `PlateSelector` both do — but the **3D Preview dialog** (`ModelViewerModal`) rendered the raw `thumbnail_url` with no token, so with auth enabled the browser fetched without one and got a 401. **Fix.** `ModelViewerModal` now wraps the plate thumbnail `src` in `withStreamToken()`, matching the two existing call sites. The token is already synced app-wide (the same global the working pickers read), and `withStreamToken()` is a no-op when auth is off, so nothing changes for non-auth setups. Covered by a component test asserting the plate thumbnail `` carries the `?token=` query param. - **Force color match dispatched a print onto the wrong PLA variant — Matte jobs went to Basic and Silk printers alike, and the wrong AMS slot on a printer holding two same-colour variants (#2650, reporter @MartinNYHC)** — With **Force color match** on, a job sliced for **White PLA Matte** was dispatched to every printer that had *any* white PLA loaded — the ones holding White PLA **Basic** and White PLA **Silk+** included — so a matte model came out glossy on the wrong machine. **Root cause.** Bambu's MQTT status reports every PLA sub-variant as `tray_type == "PLA"`; the Basic/Matte/Silk distinction is carried only in `tray_info_idx` (`GFA00` = Basic, `GFA01` = Matte, `GFA06` = Silk, …), which the 3MF's `slice_info.config` also records per filament. Three places dropped it: the Virtual-Printer queue built each force override as `{slot_id, type, color, force_color_match}` without the parsed `tray_info_idx`; the scheduler's eligibility check (`_get_missing_force_color_slots`) compared loaded trays on `(type, colour)` only — so `(PLA, #FFFFFF)` matched Basic, Matte and Silk indiscriminately and all three printers looked eligible; and the AMS slot mapper cleared `tray_info_idx` when applying the override, so even on the correct printer it could pick a different-variant tray of the same colour. **Fix.** The force override now carries the 3MF's `tray_info_idx`; a slot counts as satisfied only when a loaded tray matches type **and** colour **and** the variant (identical `tray_info_idx`, *or* either side lacks one); and the slot mapper now keeps the variant for force-colour overrides so it pins the matching tray. A blank idx on either side (custom/third-party spools report none, and older 3MFs carry none) falls back to the historical type+colour behaviour, so those setups are unaffected, and a manual filament *swap* (a preference override) still clears the idx so it matches the swapped-in spool rather than the old one. A job sliced for GFA01 now goes only to a printer with GFA01 loaded, and lands on that printer's GFA01 tray. The printer-card queue-compatibility hint (which printers show a pending job as runnable) now applies the same variant rule. Covered by scheduler tests (Matte requirement rejects Basic/Silk, accepts Matte, blank loaded idx falls back, requirement without an idx unchanged; the mapper pins the GFA01 tray over a same-colour GFA00 on both the 3MF and no-3MF paths; a preference swap still matches by colour), a Virtual-Printer test asserting the override carries `tray_info_idx`, and frontend tests for the variant-aware queue hint (rejects other variants, accepts the match, blank-idx and no-variant-data fall back). ### Security - **Patched two build-time frontend dependencies flagged by `npm audit` (GHSA-r28c-9q8g-f849, GHSA-mh99-v99m-4gvg)** — `postcss` 8.5.15 → 8.5.23 fixes a path traversal in its source-map auto-loader (`sourceMappingURL`) that could disclose arbitrary `.map` files, and `brace-expansion` (pulled in transitively by `eslint` via `minimatch`) is bumped through the existing `overrides` block (`^5.0.7` → `^5.0.8`) for a denial-of-service via unbounded expansion. Both are build/lint-time tooling only — neither is part of the shipped app, so no running Bambuddy install was exposed. `postcss` moved within its existing range; `brace-expansion` needed the pin because `npm audit fix` can't lift `eslint` to the patched transitive on its own. - **Pinned `react-router` to its most-patched 7.x (7.18.1) and documented the one remaining, unreachable advisory (GHSA-qwww-vcr4-c8h2)** — Staying current on the 7.x line matters: 7.18.1 clears 14 advisories that older 7.x releases carry, several reachable in a browser SPA (open-redirect XSS in ``/`useNavigate`, route-matching DoS). The single advisory that still flags 7.18.1 — a CSRF bypass — applies only to React Router's **RSC mode**, which requires the server runtime (`@react-router/server`, not installed); Bambuddy is a Vite SPA using `BrowserRouter`, so the vulnerable path is unreachable. There is no non-major fix (the patch landed only in the 8.3.0 major, and `react-router-dom` has no 8.x — adopting it would mean migrating every import to `react-router` plus a React peer bump), so `react-router`/`react-router-dom` are pinned to 7.18.1 and the finding is carried as a documented, fail-closed exception in the CI audit gate: a *different* react-router advisory still fails CI, and the exemption is dropped automatically the moment a non-major fix ships. `npm audit fix --force` is deliberately avoided — its suggested "fix" is a downgrade to 7.11.0, which reintroduces those 14 advisories. ## [1.2.5] - 2026-07-24 ### Added - **Skip Objects can now be selected directly on the top-down build plate** — The skip dialog pairs the plate preview with the slicer's exact per-object pick mask, so clicking a model selects the same object id the printer firmware expects. Multiple objects can be selected before one confirmation, selected and already-skipped items are highlighted on the plate, and the checklist remains available when a pick mask is missing. Selecting every remaining object keeps the printer's existing stop-print warning, and the dialog closes once the skip is confirmed. Confirming names the object when one is selected and counts them when several are, which is what plates of identically-named clones need. The existing layer, permission, and printer-command guards are unchanged. - **Assigning a spool to an AMS slot now tells you whether the printer actually accepted it (#2582, reporter @gyrene2083)** — Until now, assigning a spool to an AMS tray was fire-and-forget: Bambuddy pushed the filament setting to the printer and immediately reported success, whether or not the tray took it. When the assignment silently didn't land — the reporter's case, where a spool assigned in Bambuddy never showed up in Bambu Studio — nothing told you, and the only way to tell it had loaded was to run a flow calibration and watch for the K-profile to appear. Because a print only deducts filament from the spool assigned to the *exact* tray it pulls from, a silently-dropped assignment also meant that print recorded no filament usage, which is what made the whole thing feel random. Bambuddy now reads the AMS telemetry back after every assignment (from both **Printers → assign spool** and **Configure Slot**) and toasts the outcome: **"Filament loaded on slot X"** once the tray echoes back the filament id that was pushed, a warning if the filament loaded but the **flow-calibration (K-profile) wasn't applied**, or **"couldn't confirm the assignment — check the AMS slot"** if the tray never reflects it within ~30s. The confirmation is derived entirely from the periodic status the printer already sends (an on-demand pushall is nudged so it lands quickly), covers regular AMS, AMS-HT, and external-spool slots, and if the printer goes silent it simply stays quiet rather than inventing a failure. No configuration; the toast appears automatically on assign. - **Bed levelling, flow calibration, and nozzle-offset calibration now have an "Auto" option, matching Bambu Studio** — These three print options were previously on/off only, so the only way to run bed levelling was to force a full level before every print. Bambu Studio has long offered a third "Auto" state that lets the printer skip the calibration when it was done recently, and that state is what most people actually want. All three options (in the Schedule/Print dialog, the queue bulk-edit, and Settings → Workflow → Default Print Options) are now a three-way **Off / Auto / On** choice, and new prints default to **Auto**. "On" still forces the calibration every time; "Off" skips it entirely; "Auto" lets the printer decide. Existing queued prints and your saved workflow defaults are migrated automatically — anything that was "on" becomes "On (force)" and anything "off" stays "Off", so nothing changes for in-flight jobs until you opt into Auto. The wire encoding mirrors Bambu Studio's exactly (verified against its source), including how prints sent through a Virtual Printer inherit the slicer's own Auto/On/Off pick. ### Changed - **Orca Cloud profile sync now connects by approving a code instead of the copy-paste sign-in** — Connecting Bambuddy to Orca Cloud used to mean opening an OAuth sign-in in a new tab, watching it redirect to a `localhost` URL that fails to load, then copying that dead URL out of the address bar and pasting it back into Bambuddy. That dance existed only because Orca's auth backend (Supabase) accepts no redirect target other than `localhost`, and the deliberately-broken redirect page confused nearly everyone who reached it. OrcaSlicer has since shipped a first-class external-app pairing API (the OAuth 2.0 Device Authorization Grant, RFC 8628), so the flow is now: click **Connect**, approve a short code on your Orca Cloud settings page, and Bambuddy pairs itself — no redirect, no paste, no client secret, and it behaves identically from a LAN IP, `localhost`, or behind a reverse proxy. Bambuddy requests **read-only** access (it only lists and views your Orca Cloud profiles), keeps the pairing alive with the API's rotating refresh tokens (validated end-to-end against Orca's staging and production servers), and stores nothing beyond the issued token pair. The profile list and detail views are unchanged, so nothing downstream of the connect step looks different. The old paste-based sign-in and the email/password fallback are removed. Points at production Orca Cloud by default; `ORCA_CLOUD_API_BASE` overrides the endpoint for testing. ### Fixed - **The AMS slot popup stayed on screen and covered the filament dialog it had just opened (#2631, reporter @Jostxxl)** — Tapping **Configure** on an AMS slot left the slot's popup standing on top of the filament type/colour dialog, so the two layers overlapped: obscured content, competing backdrops, and controls of one layer sitting over the other. Most disruptive in the tablet operator workflow, where the printer is opened straight from a plate-clear scan and the next step is setting the loaded filament. **Root cause.** The slot popup is portaled to the page body at `z-[60]`, deliberately, so it can escape the stacking contexts that sibling printer cards create on the dashboard (#1336) — which also puts it *above* the Configure Slot and Link Spool dialogs at `z-50`. Nothing dismissed it: the popup is hidden only by the pointer leaving it, and a touch device never sends that event after the tap that opened it, so on a tablet it simply stayed up. On a desktop it self-cleared as soon as the mouse moved off the popup's bounds, which is why this only ever showed up on touch. **Fix.** The popup now closes itself before running any action that opens a dialog or navigates away — Configure, Assign Spool, Unassign Spool, and both Open in Inventory links, on loaded and empty slots alike — so exactly one dialog is ever on screen. Actions that report progress inside the popup (RFID re-read, Load, Unload, and Copy UUID with its confirmation tick) are deliberately unchanged, since none of them opens a dialog and closing would take their feedback with it. Covered by hover-card tests for loaded and empty slots: the popup is gone after each action, the action still fires, and it does not reappear once a pending open-timer elapses. - **Slicing a single plate failed on a filament slot the plate doesn't even use, with no way to fix it from the UI (#2628, reporter @michaelklos)** — Slicing plate 2 of a local multi-plate 3MF for an A1 failed with **"filament preset (slot 1) is not compatible with printer Bambu Lab A1 0.4 nozzle"**. Slot 1 was labelled "Filament 1 (PLA) — not used by this plate", held `SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle`, and its dropdown was greyed out — so the slice was blocked by a slot the plate never touches and the user couldn't correct. Slicing **all** plates worked. **Root cause, two independent defects.** (1) The unused-slot substitution — which replaces the profile in every slot the plate doesn't paint with, so the slicer's validators don't judge the slice on slots its G-code never touches — always copied from **slot 1**. When slot 1 is itself the unused one, that's a no-op (the reporter's log even shows it: `Substituted slot-1 filament for unused slot(s) [1]`), and with several unused slots it actively spread slot 1's foreign profile across all of them — the same poisoning #1851 removed from the picker. (2) The dialog's printer-compatibility matcher only understood BambuStudio's short `@BBL ` tag. Profiles you save yourself carry the **full** printer name instead (`… @Bambu Lab H2D 0.4 nozzle`), which the matcher classified as "can't tell" — indistinguishable from compatible — so an H2D-scoped filament was offered in the main dropdown list, auto-picked for the slot on metadata score, and handed to the slicer. **Fix.** The substitution now copies from the plate's lowest **used** slot, so an unused slot can no longer block a slice regardless of what was baked into the source file; if a plate's used slots fall outside the submitted list, the picks are left untouched rather than substituted from a slot the plate doesn't use. And the matcher now reads both tag shapes — the same two forms the AMS slot dialog has parsed since #1623 — including a trailing `(Custom)` suffix and a stray earlier `@` in the name, so a profile scoped to another printer is never auto-picked and is grouped under **Other printers** where you can still choose it deliberately. A profile whose tag names no recognisable Bambu printer stays unclassified and keeps its place in the list, exactly as before. Covered by matcher tests (long-form mismatch and self-match, display-name-vs-short-code models, the nozzle filter, `(Custom)` suffix, the A1/A1 Mini alias, unrecognisable tags, stray `@`), picker tests (the reporter's registry: the H2D profile loses to the A1 one despite a better colour and tier score, but still wins for its own printer), and substitution tests (anchors on the first used slot, doesn't poison sibling unused slots, deterministic lowest-used anchor, support slots as anchor, and the out-of-range no-op). - **A filament profile could be auto-picked for a printer it doesn't belong to when its name doesn't say which printer that is (#2628 follow-up)** — Slicing for a P2S failed with **"filament preset Bambu PLA Basic @BBL X1C 0.2 nozzle (slot 1) is not compatible with printer Bambu Lab P2S 0.4 nozzle"** — naming a profile that appeared nowhere in the slice dialog. The dialog showed `Overture PLA Matte @0.2` in every slot, including the one the plate actually uses; the slicer resolves that profile's inheritance chain and validates the X1C system profile at its root. **Root cause.** The dialog decides whether a profile fits the selected printer from the profile's own `compatible_printers` list, and falls back to reading the printer out of its NAME. This profile's name carries a nozzle size but no model, so the name fallback couldn't classify it — and the list, though present on the imported copy, is not shipped by every source: Bambu Cloud omits it from its listing on purpose (the per-profile endpoint is rate-limited), and Orca Cloud's listing carried it but Bambuddy only read the filament type and colour out of it. "Can't tell" is treated as usable, so the profile scored its way into the auto-pick for a printer it was never built for. **Fix.** Orca Cloud profiles now surface their own `compatible_printers` (it was already in the data Orca returns — no extra request), and the existing same-name bridge that lends Bambu Cloud entries their filament type and colour from another source now lends the compatible-printer list too, in both directions between the cloud sources. So whichever copy of a profile knows its printers teaches the ones that don't. As a last resort for profiles no source can classify, a bare `@` tag in the name is now read as a nozzle size: it can rule a printer out (0.2 profile, 0.4 printer) but never rules one in, since a size says nothing about the model — and a number that can't be a nozzle (`@2026`) is ignored rather than guessed at. Profiles that still can't be classified keep their place in the list exactly as before; ones that can are grouped under **Other printers**, where you can still pick them deliberately. Covered by preset-listing tests (Orca list extraction incl. the bare-string form, malformed/empty lists staying unclassified, the bridge in both directions and for both process and filament, never overwriting a list an entry already has, no-donor staying unclassified, and the borrowed list being copied rather than shared across the per-user caches) and matcher tests (0.2-vs-0.4 rejection, matching size staying unclassified, the `0.2 nozzle` / `0.2mm` spellings, numeric `0.20` vs `0.2`, implausible sizes ignored, and model-bearing tags still taking the model path). - **Switching off an accessory smart plug at the end of a print knocked the printer into "Unknown" and stalled the queue (#2629)** — With an end-of-print auto-off on a plug that powers a *filter fan* (not the printer), the printer flipped to **Unknown** the moment the plug switched off and the queue stopped dispatching to it until a manual **Force Refresh**. The printer itself never went anywhere — MQTT traffic continued a second later. **Root cause, two parts.** (1) Bambuddy treats *any* plug linked to a printer as that printer's power supply: every auto-off — time delay, temperature delay, the time-of-day schedule, a resumed-after-restart off, and a manual off from the plug card — immediately marked the printer offline, whether or not the plug feeds it. (2) That mark was **unrecoverable**. It forces `connected=False` and the state to `unknown`; `connected` heals on the very next MQTT message, but the state does not — the printer state is only rewritten when a status frame carries `gcode_state`, and the steady-state frames a P1S sends are partial. So `unknown` stuck until the next full status push, and the scheduler (which dispatches only to `IDLE`/`FINISH`/`FAILED`) treated the printer as permanently unavailable. **Fix.** The offline mark is now an explicitly *presumed* power cut: the state it overwrites is remembered, and the presumption is undone as soon as the printer sends another report on its own topic, since inbound traffic proves the power was never cut (a frame that does carry `gcode_state` still wins, and the recovery re-broadcasts so the UI un-greys). A printer whose power really was cut sends nothing, so it correctly stays offline — this also repairs the same stuck-`unknown` for a genuine printer plug whose MQTT resumes without a full push. On top of that, each plug now carries a **Powers the printer** toggle (shown when a printer is linked, in both the plug card and the add/edit dialog): leave it on for the plug that feeds the printer, turn it off for accessories — filter fan, chamber light, enclosure heater — and switching those off no longer touches the printer's state at all. Existing plugs are migrated as power plugs, so nothing changes until you say otherwise. The same flag also fixes the queue's power-on step, which used to pick whichever linked plug came first and could spend the whole power-on timeout waiting for a filter fan to boot a printer; it now picks the plug flagged as the power source. Covered by an end-to-end regression test that drives the real MQTT client, printer manager and scheduler through the reported sequence (accessory off → printer keeps talking → queue dispatches again; real power cut → stays offline), MQTT-client tests (presumed off remembers and restores the state, a partial frame recovers it, a real `gcode_state` overrides it, request-topic traffic does not count as proof of life, a reconnect discards the saved state, and a genuine second power cut is not undone), smart-plug tests (accessory plugs switch off without marking the printer offline on all four off-paths, power plugs keep the old behaviour), scheduler tests (power-plug selection), and migration tests on both SQLite and PostgreSQL. - **A2L "AMS Lite" slots showed as empty and never deducted filament** — On an A2L with the 4-slot AMS Lite attached, physically loaded slots could display as empty, filament usage was never deducted from the loaded spool, and linking an AMS-Lite slot to a Spoolman spool failed outright. **Root cause.** The A2L reports its AMS Lite as unit **id 16**, but the firmware is internally inconsistent about it: its slot-presence bitmasks sit at the bit positions for unit **6** (bit base 24), and it reports the actively-feeding slot (`tray_now`) as a **local** 0-3 index rather than a global tray id. Bambuddy's `ams_id*4+slot` convention, fed the raw id 16, probed bit positions 64-67 (always zero) and marked every loaded slot empty; the local `tray_now` was read as a global id, so usage was attributed to the wrong spool (or dropped); and the `ams_id <= 7` database constraint rejected id-16 Spoolman links. **Fix.** The AMS Lite is now normalised from unit **16 → 6** at the MQTT ingest boundary, so its global tray ids land at **24-27** — matching the firmware's own bit base, working with every existing `ams_id*4+slot` consumer (slot presence, usage tracking, deficit warnings, the scheduler, Load/Unload), colliding with nothing, and passing the database constraint. The local `tray_now` is globalised to `24+slot` so deduction hits the right spool, the valid-tray guards accept the 24-27 range, and the printer card labels the unit "AMS Lite". Prints dispatched to the Lite build the correct `ams_mapping2` (`{ams_id:16, slot_id:0-3}`) and flat mapping (local 0-3), both confirmed against the firmware's own mapping. Outbound slot commands (set filament, reset, load/unload, calibration, RFID refresh) translate the normalised id 6 back to the physical 16 on the wire, centralised in one helper. The whole normalisation is self-scoping — only unit id 16 is ever touched, so every other printer and AMS type is byte-for-byte unaffected. Verified against the reporter's live captures (slot presence via `tray_exist_bits`, and `tray_now` while printing a known physical slot); mixed setups (a regular AMS attached alongside the Lite) are out of scope and log a warning, and one uncaptured wire encoding (the physical global tray field on `load`/`calibration` commands) is extrapolated and isolated to the single translation helper. - **Bambu Cloud kept dropping to "sign-in expired" and forcing constant re-logins, even while cloud features still worked** — Since the #2562 status rework, the Bambu Cloud sign-in would flip to "expired" shortly after logging in, over and over, with nothing in the logs to explain it. **Root cause.** The rework made a genuine 401 from Bambu durably record the stored token as dead (`cloud_token_invalid_at`) — correct in principle, but it treated **any** HTTP 401 from **any** cloud or MakerWorld call as a dead token. Bambu returns 401 for plenty of non-fatal reasons (an endpoint-, region- or scope-specific refusal; a Cloudflare-edge blip; a brief backend hiccup), so a single stray 401 from any one call — including a background poll — durably signed the whole cloud integration out until the next manual re-login. Because the flag lives in the database, a setup running more than one Bambuddy instance against the same database made it worse: a stray 401 seen by either instance signed the user out in both. **Fix.** Invalidation now fires only for Bambu's documented token-expiry response — `{"code":4,"error":"Please login."}` — and never for a plain or unparseable 401, which is treated as transient (the request fails, but the session is left signed in). The same signature gate is applied to the MakerWorld path, which shares the token. A genuinely expired token is still detected and surfaced exactly as before; what stops is the false "expired" on a working session. Covered by service tests for both engines: `code:4` and the "Please login." text invalidate, while a benign `code:1`/`forbidden` 401, an unparseable 401, and (for MakerWorld) a signature-less 401 with a token do not. - **Every SpoolBuddy screen crashed the moment a text field was focused (#2616, reporters @MartinNYHC, @agentdr8)** — Tapping the Search box on the SpoolBuddy inventory, or the Search / Color Name / Brand fields on the write-tag New Spool tab, blanked the UI with a minified React error #130 ("Element type is invalid… but got: object"). It hit both internal and Spoolman inventories, so it was not data-specific. **Root cause.** The SpoolBuddy shell mounts an on-screen keyboard (`VirtualKeyboard`) that pops up on `focusin` for any text input — which is why every field on every SpoolBuddy page tripped it, while the main app (no on-screen keyboard) was fine. That component does `import Keyboard from 'react-simple-keyboard'`, a CommonJS package, and under the current bundler's CJS→ESM interop the default import resolves to the module **namespace object** (`{ KeyboardReact, default }`) rather than the component itself. Rendering that object as `` put an object where an element type belongs, and React threw. (The discrepancy is interop-specific: the test runner hands back the real component, so it only manifested in the browser build — which is why it needed a runtime, not a type, fix.) **Fix.** A small `resolveInteropDefault` helper unwraps such an interop-wrapped default: it returns the value as-is when it's already a usable element type (function/class, tag string, or a `$$typeof`-marked forwardRef/memo/lazy) and otherwise falls through to `.default` and named exports. `VirtualKeyboard` resolves the real `react-simple-keyboard` component through it, so the keyboard renders under any interop shape. Covered by unit tests for the resolver against the object shape, a named-only export, a forwardRef object, and a bare component, plus a render test that mounts the keyboard on input focus. - **The streaming overlay (`/overlay`) showed nothing in OBS when login was enabled (#2613, reporter @MartinNYHC)** — With authentication on, the overlay page worked when opened in a browser where you were already signed in, but stayed blank in OBS. The reporter suspected their Cloudflare/remote setup; it was unrelated. **Root cause.** The `/overlay/{id}` *route* renders without a login, but every piece of data it draws is auth-gated — printer status and name (`PRINTERS_READ`), one setting (`SETTINGS_READ`), and the camera stream (a camera-stream token). In your own browser those ride the JWT from local storage and the app-wide stream-token sync; OBS is a fresh browser with **no session**, so the status calls 401'd and the overlay never populated (the same would happen in any private/incognito window — remote access was never the cause). Unlike the Cam Wall (`/camwall?token=…`), the overlay had no token mode, and a long-lived token couldn't help because the JWT-gated status/settings endpoints reject it. **Fix.** The overlay is now a self-contained kiosk surface. A new **Streaming Overlay** long-lived-token scope is offered under Settings → API Keys (with a ready-made `/overlay/{id}?token=…` URL copied once on creation); the overlay page reads `?token=` from the URL and, in that mode, authenticates its status and camera calls with the token instead of a JWT (and skips the WebSocket, falling back to its existing 2 s poll). A new token-authenticated `GET /printers/{id}/overlay-status` returns exactly the fields the overlay draws — name, camera rotation, live print state, and the one setting — and nothing else. The scope is deliberately **separate from `camwall`**: the overlay names the file on screen, which the Cam Wall is trusted never to expose, so folding it in would have silently widened every existing wall token. The logged-in path (opening the overlay while signed in) is unchanged. Docs updated to explain the token and stop claiming the overlay needs no authentication. Covered by backend tests (scope boundaries in both directions — an overlay token can't reach the Cam Wall feed and a camwall/camera-stream token can't reach the overlay feed — plus the payload shape, disconnected-printer shape, and revoked/absent/garbage-token rejection) and frontend tests (kiosk mode reads the token feed and carries the token to the camera, never touches the JWT-only status endpoint or a socket; the mint UI offers the scope and hands over the assembled OBS URL). - **Reassigning a queue item while it was dispatching split it across two printers (#2615, reporter @Jostxxl)** — Editing a queue item's printer while its FTP upload was already in flight left the queue row pointing at one printer while the archive, expected-print registration, and the physical `project_file` command had gone to another. On a farm this made the reassigned-to printer look broken (marked `printing` but never sent the job), left the row permanently inconsistent, and could trigger a duplicate dispatch after a restart. **Root cause.** A queue row stays `status='pending'` for the entire (multi-minute) FTP upload — status only flips to `printing` at the very end. The edit route only blocked non-`pending` rows, so a `PATCH` during the upload window was accepted; the in-flight dispatch kept using the printer it had snapshotted at the start, while the DB row's `printer_id` changed underneath it. The existing #1853 CAS guards *cancellation* mid-dispatch, not *reassignment*. **Fix.** A `dispatching_at` claim is stamped atomically on the row (`WHERE status='pending' AND dispatching_at IS NULL`) the moment the scheduler begins dispatching, before any slow I/O, and cleared when dispatch ends. While it's held, both edit routes reject changes — the single-item `PATCH` returns **409** (re-checked immediately before the write to close the read-modify-write gap), and bulk edits skip the row — and the scheduler's selection query won't re-pick it. Startup reconciliation clears any claim orphaned by a crash mid-dispatch (no dispatch coroutine survives a restart, so every claim present at boot is stale), so a stale token can never wedge an item out of the queue. The row stays `pending` throughout, so no status-consumer, UI, completion, or reconciliation path had to change. To move a dispatching item, cancel it first (the coordinated escape) and re-queue. New column `print_queue.dispatching_at` (nullable timestamp, dialect-safe DDL — SQLite `DATETIME` / Postgres `TIMESTAMP`). Covered by scheduler tests (claim is exclusive, fails on non-pending rows, releases on every exit, skips an already-claimed row, startup clears stale claims) and API tests (reassign returns 409 with `printer_id` unchanged, bulk skips the claimed row, an unclaimed pending row still edits normally). - **A single plate printed from a multi-plate 3MF recorded the whole file's filament in statistics (#2614, reporter @Jostxxl)** — Dispatching one selected plate of a sliced multi-plate 3MF through the queue could log the **entire file's** filament against that one plate. The reporter's `heart 3.gcode.3mf` has 22 plates totalling ~12.0 kg; every completed plate recorded `12006.49 g`, so 13 runs inflated lifetime/user/project/filament stats by ~156 kg from one file. **Root cause.** The per-run value written to `PrintLogEntry.filament_used_grams` prefers the AMS-tracked spool delta, but when the tracker measured nothing (no inventory assignment on the printer) a *completed* run fell back to `PrintArchive.filament_used_grams` — which is deliberately the **sum over every plate** of the source 3MF (correct for the archive card and project rollup, #1593). The archive's `plate_id` (persisted by #2603) was never consulted on this path, so the whole-file total was copied verbatim; `cost` had the same defect, falling back to the whole-file `archive.cost`. **Forward fix.** When the archive carries a `plate_id` and its 3MF is on disk, the completed-run fallback now uses that plate's own slicer estimate (`extract_plate_metadata_from_3mf`, the same plate-scoped parse the inventory tracker uses), and scales cost by the plate's share of the whole. The tracker-measured path is unchanged (measured spool deltas still win) and single-plate archives are unaffected (plate value equals the whole-file value). **Backfill.** A startup migration repairs rows already written: for completed print-log entries whose stored grams **exactly equal** the linked archive's whole-file value (the mis-copy signature) and whose archive has a `plate_id` and an on-disk 3MF, it recomputes the plate-scoped grams + cost. The exact-match guard means tracker-measured rows (a rounded spool-delta sum) and partial-progress rows (scaled to progress) are never touched; it's idempotent (a corrected row no longer matches) and data-only, identical on SQLite and Postgres. Logs how many rows and how many grams of over-count it removed. Covered by unit tests for the forward helper (plate scoping, cost scaling, fallbacks when there's no plate_id / no file / unreadable estimate) and the backfill (mis-copy repaired, tracker/partial rows untouched, missing-3MF skipped, single-plate not relabelled, idempotent). - **Progress notification ran off the left edge of the screen in the installed iPhone PWA (#2612)** — On an iPhone 13 Pro with Bambuddy added to the Home Screen, the print-dispatch progress toast was clipped off the left side of the display — text like "prints", "plate_6", and "MB (21.0%)" bled past the edge. **Root cause.** The toast viewport is anchored `right-20` (80 px from the right, to clear the bug-report bubble) and the dispatch toast has a fixed `w-[420px]`. On a phone that's 390 CSS px wide, 420 + 80 overflows the left edge by ~110 px — the toast simply didn't fit. **Fix.** Every toast now carries a viewport-relative `max-width` (`calc(100vw - 6rem - safe-area insets)`) so it can never exceed the screen; on desktop the 420 px still wins. The viewport's position is also now safe-area-aware (`env(safe-area-inset-*)` on bottom/right) so an installed PWA clears the home indicator and a landscape notch, and the per-job filename row gets `min-w-0`/`shrink-0` so long names truncate instead of pushing the toast wide at the narrower phone width. Frontend-only; no backend, schema, or i18n change. Covered by a test pinning the viewport-relative width cap. - **Multi-plate queue prints lost the selected plate in Print History and a stopped-while-offline print stayed "printing" (#2603, reporter @Jostxxl)** — Cancelling a print queued from a specific plate of a multi-plate 3MF showed it in Print History as **Plate 1**, so you couldn't tell which plate to requeue. **Root cause.** The archive derives its plate from the *filename*, but a whole multi-plate 3MF uploads under one name with no plate suffix, so the parser defaulted to plate 1 and `extra_data` held all-plates aggregate metadata; the queue row kept the correct plate but nothing copied it onto the archive, which had no plate field at all. **Fix.** `print_archives` gains a nullable `plate_id`, copied from the queue item at dispatch (both the archive-based and library-file paths), exposed in the archive API, and rendered in Print History (falling back to no plate label only when genuinely unknown). A startup backfill copies the plate onto existing archives from their linked queue rows, so already-cancelled prints recover their plate. Additionally, **stopping a printing item while the printer was offline left the linked archive stuck at "printing"** — the queue row was cancelled but, with no printer to send an MQTT completion, nothing ever reconciled the archive. The offline-stop path now closes the archive out directly (status `cancelled`, `failure_reason` "Stopped by user (printer was offline)"); the online path is unchanged and still leaves the archive to the MQTT completion event. Column add + backfill are identical on SQLite and Postgres. Covered by tests for plate persistence, the backfill (including no-clobber/idempotency), and the offline vs online stop reconcile. - **`queue_max_concurrent_uploads` behaved as a per-batch cap instead of a refillable pool (#2602, reporter @Jostxxl)** — On a large farm, unused upload slots sat idle whenever any upload from the current batch was still running. **Root cause.** `check_queue()` awaited `_dispatch_selected()`, which awaited `asyncio.gather()` over the whole selected batch before returning — so the scheduler's run loop was blocked until the *slowest* FTP transfer in the batch finished. A 96 MB 3MF that took 513 s to upload left 15 of 16 configured slots unused for 8.5 minutes on a 93-printer farm, even as other printers came free; jobs that became eligible during the long upload couldn't be dispatched. The batch-await was load-bearing for one reason: `_start_print` flips a row `pending → printing` only *after* its upload completes, so returning early would have let the next pass re-dispatch the still-`pending` in-flight rows. **Fix.** Uploads now run as independent background tasks tracked in a `_inflight` pool. Each tick excludes in-flight item rows (and their printers) from selection, launches at most `limit − len(_inflight)` new uploads, and returns immediately — so a freed slot refills on the next fast (3 s) tick instead of waiting out the whole batch, and the configured limit finally behaves as a continuously-refillable worker pool. The no-double-dispatch invariant the batch-await used to provide is now carried by the in-flight exclusion; the `pending → printing` CAS, the busy-printer guard (#2598), the per-printer dispatch hold, auto-drying exclusion (in-flight printers stay out, including on the no-pending-items path), and per-item failure isolation are all preserved and run per task. Investigated with the reporter's large-farm hotfix and reproduction; covered by rewritten pool tests (cap holds across refills, freed slot refills, in-flight item/printer excluded from re-selection, check_queue returns without awaiting uploads). - **Configuring a built-in/generic filament on an AMS slot reverted to the old profile a moment later (#2604, reporter @Jostxxl)** — Selecting a built-in preset (e.g. Generic ABS) through **Printer → AMS slot → Configure** briefly showed the new material on the printer, then the slot snapped back to whatever was there before (e.g. an old Generic PETG). **Root cause.** The Configure AMS Slot modal sends built-in, local, and Orca-generic presets with a `GF*` `tray_info_idx` but an **empty** `setting_id` (those presets carry no Bambu Cloud setting id of their own), and the `configure_ams_slot` route forwarded that empty value straight to `ams_filament_setting`. The firmware treats a slot that has a filament id but no setting id as half-configured: it accepts the update, then reverts to its previously stored profile. The inventory/assignment path already guards against this by deriving the setting id from the filament id, but the manual Configure path didn't, leaving two inconsistent code paths. **Fix.** `configure_ams_slot` now back-fills `setting_id` from the resolved `tray_info_idx` via `filament_id_to_setting_id` whenever the client sent none (e.g. `GFB99` → `GFSB99`), mirroring the inventory path. Doing it server-side also protects API callers and any future frontend. `P*` user presets and already-`GFS*` values are left untouched, and an explicitly-supplied `setting_id` (including the `PFUS*` pair) still passes through unchanged. Covered by tests for the built-in empty-`setting_id` case and the material-only generic-fallback case both publishing a derived `GFS*` id. - **The HT-A (AMS-HT) spool vanished a few seconds after every power-on (#2594, reporter @GuillaumeHouba)** — On an H2C, the spool in the HT-A high-temp AMS on the left nozzle showed correctly with its RFID assignment on power-on, then disappeared seconds later; the regular AMS spools stayed. **Root cause.** The AMS merge in `_handle_ams_data` clears a tray when it receives a partial `{id, state}` update whose `state != 11` — the rule that lets 4-slot AMS units (e.g. H2D) report an emptied slot with just `{id, state}` and no `tray_type` (#784), where `11` = loaded. But an **AMS-HT** (single-tray high-temp dry box, unit id ≥ 128) reports its *loaded* tray as `state=9`, not 11 — it doesn't feed filament into a shared buffer the way a 4-slot AMS does. So the partial `{id:0, state:9}` the printer sends for the HT tray on power-on was misread as "slot emptied," and Bambuddy wiped the tray's `tray_type` / RFID / Spoolman assignment. The support log showed it plainly: every "state=9 (not loaded) — clearing stale tray data" was on AMS 128, never on the regular AMS unit 0 (which correctly reports 11). **Fix.** The `state != 11 → empty` heuristic is now skipped for AMS-HT units (id ≥ 128); their differing single-tray state semantics mean a partial state update must not clear a present spool. A genuine HT spool removal still clears through the explicit `tray_type == ""` update and the `tray_exist_bits` cleanup, both unchanged, and regular AMS behavior (id < 128) is untouched. Covered by tests for the HT tray surviving a `state=9` partial, the HT still clearing on an explicit empty, and the existing regular-AMS `state=9`/`10`/`11` cases. - **A start-print dispatched to an already-busy printer could cancel the running job (#2598, reporter @khaosdoctor)** — On an A1 mini across a night of prints, jobs were cancelled with no apparent cause; debug logs showed Bambuddy sending `project_file` twice ~3 minutes apart with no completion between, and the printer answering `0500_4004` ("Device is busy and cannot start a new task") — which on that model cancels the RUNNING print. **Root cause.** `start_print()` in the MQTT client guarded only on connection state (`self._client and self.state.connected`) — it published `project_file` with no check on the printer's `gcode_state`. The scheduler *does* gate dispatch on an idle check, but that check treats `FINISH` as idle, and a printer can keep reporting `FINISH` for tens of seconds *after* it accepted a `project_file`; combined with a dispatch watchdog that reverts a queue item and releases its dispatch hold when it doesn't observe the active-state transition in time (#2555), a re-selected item could reach the FTP upload while the printer had actually started, so the start command landed on a live print. **Fix (defense-in-depth).** (a) `start_print()` now refuses to publish `project_file` when the printer is in an active state (`PREPARE` / `SLICING` / `RUNNING` / `PAUSE`) and returns without sending — a single guard at the one publish choke point that every dispatch path (queue, manual, webhook, Virtual-Printer forward) funnels through. `IDLE` / `FINISH` / `FAILED` remain valid start targets. (b) The scheduler re-checks the live printer state right before the FTP upload and *defers* a busy printer (leaves the item pending for a later tick) instead of uploading and dispatching — no wasted transfer, no collision. (c) If the printer goes busy during the upload window and the start command is refused, the scheduler reverts the item to pending (a deferral) rather than marking it failed — a busy printer is not a failure. Covered by tests for the client-level guard (refused while busy, published while idle, guard precedes the connection check) and the scheduler deferring both before the upload and after a busy-refused start. Note: a transport-level MQTT QoS-1 replay on reconnect would bypass the client guard, but the dispatch/watchdog reconnect path already hard-resets the client with a fresh session so it has no inflight `project_file` to replay. - **Three more idle-in-transaction / thundering-herd paths surfaced by continued farm testing (#2572, reporter @Jostxxl)** — On `origin/dev` with 93 printers and multiple concurrent UI clients the reporter timestamp-correlated the surviving pool pressure to three remaining paths, none of them auth-related. **(a) The scheduler held its per-item session across preheat and the FTP upload.** `_dispatch_selected` opens one `async_session` per queue item and hands it to `_start_print`, which reads the printer/archive rows up front and then runs the preheat/heat-soak wait and the FTP delete+upload — all on the transaction opened by those first `SELECT`s. One correlated session's last statement was a `settings` `SELECT` at the exact moment the log showed "Starting queue item" → preheat → "FTP upload started" for a 96 MB 3MF; the transaction stayed open for the whole transfer. (This refines the earlier note that the scheduler paths were "already bounded" — the per-item session itself was the hold.) `_start_print` now commits right before the FTP delete/upload, and `_preheat_and_soak` commits after its read phase and before the up-to-15-minute soak wait (both loops touch only `printer_manager` state and `asyncio.sleep`, no DB). `expire_on_commit=False` keeps the loaded rows readable; the status writes afterward (upload-failure path and the pending→printing CAS) transparently open a fresh transaction. **(b) `/cloud/filament-info` held its request session across sequential Bambu Cloud round-trips and single-flighted nothing.** The route took its session via `Depends(get_db)` (held for the whole request), read the stored token, then looped over the uncached ids issuing one external `get_setting_detail` HTTP call each — so the session sat idle-in-transaction across N cloud calls, and because the printer overview mounts one filament-info request per printer card, several browsers hit the same uncached preset at once and each issued its own cloud call. The route now releases the transaction (`rollback`) right after the token read and before the cloud loop (Phase 3's local-preset read reopens a fresh one), and concurrent misses for the same id single-flight through one shared cloud call. **(c) `/printers/{id}/cover` had no in-flight coalescing.** The connection was already released before the download, but simultaneous clients could all miss the cache and each run the full multi-path FTP lookup + 3MF extraction (one observed transfer pulled an 81 MB 3MF while real print uploads were in flight). Identical concurrent cover requests now coalesce: the first becomes the leader and the rest await it, then serve from the positive/negative cache it filled. Also adds `pool_use_lifo` (PostgreSQL default on, `DB_POOL_USE_LIFO` override, shown in `/system/db-pool`) so a bursty farm keeps a small hot connection set busy and lets excess overflow connections age out via `pool_recycle` instead of churning the whole pool. Covered by tests: the scheduler releasing its connection before both the FTP upload and the soak wait, the filament-info single-flight (concurrent misses share one cloud call, cache-hit skips cloud, a failed fetch leaves no stuck in-flight entry), and concurrent cover requests downloading once. - **An "Any [model]" queue job dispatched from a Virtual Printer printed to the empty external spool and aborted at layer 0 (#2595, diagnosed by @Sawtaytoes, PR #2596)** — On a farm of identical X1Cs with different filaments loaded per AMS, the intended flow — VP in Queue mode, auto-dispatch, target **Any X1C**, force-colour-match picks the printer that has the right spool — sent the job to the correctly-matched printer and then failed: the printer ignored the AMS, pulled the empty external spool, and aborted with "not enough filament", even though the mapped slot was loaded (the same print via a specific printer, or straight from the slicer, worked). **Root cause.** A slicer talking to a Virtual Printer only ever sees the VP's external spool — a VP advertises no AMS — so the slicer sends `use_ams=false`, and VP intake stamps that onto the queue item. But an "Any [model]" item is colour-matched to a real printer *at dispatch*, resolving a real AMS slot in `ams_mapping`; the scheduler still forwarded the stale `use_ams=false`. The print-command builder only ever forced `use_ams` **off** (the all-external case) and never back **on**, so `use_ams=false` shipped alongside `ams_mapping=[]` → external spool → abort. **Fix.** For single-nozzle printers the resolved mapping is now authoritative: a real AMS tray (0-253) forces `use_ams=true`; an explicit external selection (254/255) still forces it false; an unresolved `-1` mapping does neither (preserving the #2589 contract — it should have been recomputed upstream, and must not be silently promoted to AMS or downgraded to external). Dual-nozzle printers are untouched, where `use_ams` encodes nozzle routing rather than an on/off flag. Because the correction lives at the single command-builder choke point, it fixes the VP, queue, and manual paths alike. Covered by tests for the VP `false`+real-tray promotion, padded mappings, all-external staying off, unresolved `-1` staying put, the original all-external downgrade, and the dual-nozzle bypass. - **Reconnecting or restarting inflated Stats → Total Print Time by hundreds of hours on large farms (#2592, reporter @Jostxxl)** — On the reporter's farm a restart pushed Total Print Time from ~1,500h to 3,215h. When a printer reconnects, the connected edge runs `reconcile_stale_active_prints`, which closes out every archive still stuck in `status="printing"` (missed completions, disconnects, restarts) by synthesising an aborted `on_print_complete`. That wrote a `PrintLogEntry` whose duration was `completed_at - started_at` — but for a reconciled archive the real end time is unknown: the print stopped somewhere during the disconnect, and `completed_at` is only the reconnect moment. So each stale archive banked its entire multi-day gap as print time (one row was 51.9h), and a printer with several stale archives contributed hundreds of fabricated hours at once. Worse, the Stats total *recomputed* `completed_at - started_at` whenever the stored duration was falsy, so storing NULL wouldn't have helped. Reconciled completions now log an explicit `duration_seconds = 0` (honest "no measured runtime") and the two Stats time paths trust a stored 0 instead of recomputing from the stale timestamps — legacy rows that never recorded a duration still fall back as before. Reconciled aborts also get a truthful `failure_reason` ("Stale - reconciled after reconnect, end time unknown") instead of being mislabelled "User cancelled". Genuine long prints are untouched: nothing is capped, a still-running >24h print is never treated as stale, and a real >24h run keeps its full measured duration. Re-running reconciliation is already idempotent (the archive flips to `aborted`, so it isn't re-selected). Existing inflated rows from before this fix are not auto-corrected — they're indistinguishable from real cancellations in the database, and a blanket cap would clobber genuine long prints; the reporter repaired his own rows by hand. Covered by tests for the multi-day reconcile, multiple stale archives per printer, a retained >24h print, and the Stats total ignoring reconciled time while still counting real runtime. - **H2C prints intermittently recorded no filament and never deducted from inventory (#2582, reporter @gyrene2083)** — On an H2C (firmware `01.02.00.00`) filament usage sometimes wasn't deducted and the Print Log showed no filament for that print; the reporter confirmed the tell-tale detail — the failed print's archived `.3mf` didn't exist to download. Filament totals, the Print Log filament column, and the weight deduction all read the sliced 3MF's data, so when that file can't be pulled off the printer the print drops to the no-3MF fallback archive and every one of them comes up empty. The download itself was the failure: the H2C is the same H2 generation and the same firmware line as the P2S, whose FTPS data channel trips a vsFTPd + TLS 1.3 session-reuse bug on Python 3.13 (#1401) — and the X2D hit the sibling handshake variant (#1638). Both were fixed by capping that model's FTP control/data channel to TLS 1.2 via the per-model FTP profile registry, but the H2C had no entry and so ran on the Python-default TLS 1.3, leaving its 3MF downloads to fail the same way (intermittently, matching the "sometimes works, sometimes doesn't" report — the session-reuse race rather than a hard handshake failure). The H2C now gets the same `cap_tls_v1_2` profile as the P2S/X2D (with its `O1C`/`O1C2` SSDP codes mapped to it), so the sliced 3MF comes off the printer reliably and the slice data — filament total, Print Log filament, and the inventory deduction — is populated again. H2D is deliberately left on the default profile; it negotiates TLS 1.3 without this fault. - **An unresolved AMS mapping silently dispatched a P1S print to the empty external spool (#2589, reporter @Jostxxl)** — A queued P1S job with a regular AMS attached, two compatible PETG spools loaded, and nothing on the external spool holder started against the *external* feed and paused seconds later with a filament-runout HMS. The queue row was correct on its face — `use_ams=true` — but carried `ams_mapping=[-1]`, and Bambuddy turned that into a print with no AMS. Two faults combined. **A `-1` was read as "external spool."** The command builder's rule for "all slots are external, so drop `use_ams`" tested `t < 0 or t >= 254` — folding the *unresolved* sentinel (`-1`) in with a genuine external selection (`254`/`255`). An explicit external print serializes as `[254]`; an unresolved slot serializes as `[-1]`, and the two mean opposite things — one is "use the spool holder", the other is "we never worked out which tray." Only `>= 254` may now force `use_ams=False`; `-1` never does. **The unresolved mapping was trusted instead of recomputed.** The scheduler only computes a mapping when the row has *none*; a stored `[-1]` is non-empty, so it looked "already resolved" and was passed through verbatim — even though the backend had the live AMS trays and the plate's filament requirements right there and could have matched them. Dispatch now recomputes whenever the stored mapping is entirely unresolved, so a bogus `[-1]` self-heals against the trays actually loaded (and any pre-existing stuck row heals on the next scheduler pass); if nothing compatible is loaded it is cleared rather than sent, so the firmware reports a clear mapping error instead of quietly printing to an empty feed. **Where the `[-1]` came from.** The Print dialog builds the mapping from the selected printer's live status; if you submitted a single-printer job in the instant before that status query resolved, it matched against zero known trays and serialized every required slot as `-1`. The dialog now waits for the printer's AMS status before it will submit (showing a brief "Waiting for AMS status from …" notice), and the mapping hook returns *no* mapping rather than an all-`-1` one while the trays are unknown — so the scheduler resolves it at dispatch. A genuine no-match with trays present still serializes `-1` and surfaces the mismatch as before. **Tests.** Backend: the command builder keeps `use_ams=true` for `[-1]`/`[-1,-1]` and a padded `[-1,-1,5]`, still drops it for an explicit `[254]`; the scheduler recomputes a stored `[-1]`, leaves a resolved (or manually-overridden) mapping untouched, and clears an unresolvable one. An existing test that asserted the old `[-1] → use_ams=False` behaviour was corrected to the fixed contract. Frontend: the mapping hook returns `undefined` while status is loading, resolves to the AMS tray once it arrives (type-only match with strict colour off), and still emits `-1` for a real mismatch. Full backend suite and the PrintModal/mapping frontend suites green. - **Pushover Emergency priority (2) was rejected by the Pushover API (#2586)** — Setting a Pushover provider to priority 2 (Emergency) made every notification fail with Pushover's own error that `retry` and `expire` are required. Pushover *mandates* those two parameters for Emergency alerts — `retry` is how often it re-alerts (minimum 30 s) and `expire` is when it stops (maximum 10800 s / 3 h) — and Bambuddy never sent them, so the message was refused before it left the app. Priority 2 now works: two new optional fields (Emergency Retry / Expire) appear on the Pushover provider **only when priority is set to 2**, default to a sensible 60 s / 3600 s, are clamped to Pushover's legal 30–10800 s range, and are sent only at priority 2 (Pushover ignores them at other priorities). Emergency alerts now keep re-alerting until acknowledged, as intended. - **P2S RTSP timeout could leave the fan-out camera stream permanently stalled (#2580, reported and diagnosed by @ronaldheft, fix shape from PR #2581)** — After an RTSP read timeout, the stream cleanup killed the stalled ffmpeg and then waited *unbounded* for it to be reaped. A SIGKILLed ffmpeg stuck in uninterruptible I/O on a dead RTSP socket can take arbitrarily long to exit, so the fan-out stream coroutine sat parked in that wait — in the reported case for 12 hours — while every new viewer attached to the stalled broadcaster and got no frames (snapshots and diagnostics kept working, since those open fresh connections). The post-kill wait is now bounded (2 s): on timeout the stream abandons the zombie — the orphan janitor's /proc scan reaps it on its next pass — and proceeds to its normal reconnect, so live view recovers by itself. The same unbounded wait hid in two more places, both bounded too: the camera *Stop* endpoint (which would hang the very request a user makes to recover a stuck stream) and the periodic orphan-cleanup janitor itself (which is the safety net that recovers stalled streams, and so can least afford to block). - **Queue edit showed the sliced-for model as the scheduler target, and a cross-model queue row could dispatch G-code to an incompatible printer (#2578, reporter @Jostxxl)** — Two bugs with one root. The "Any \" assignment button labeled itself from the file's slice metadata while the scheduler actually used the row's `target_model`, so an X1C-sliced item targeting H2D read "Any X1C" above "Scheduler will assign to first available idle H2D printer". Worse, the mismatch could be *created* silently: the sliced-for model loads asynchronously, and clicking "Any Model" before it arrived pre-selected the first model alphabetically — on a mixed X1C/P1S/H2D farm that's H2D — after which the model dropdown hid itself, leaving no way to see or fix the wrong target. Nothing downstream checked compatibility, so the scheduler would happily hand X1C G-code to an H2D. Now: the target model is never silently defaulted (the dropdown stays visible in model mode, pre-selected to the sliced-for model when available, and back-fills once the metadata loads); the button reflects the actual target; a warning shows when the target differs from the sliced-for model. Compatibility is enforced end-to-end with an explicit G-code interchange family table (X1/X1C/X1E/P1P/P1S interchange; everything else exact-match — files without slice metadata are never blocked): incompatible models are disabled in the dropdown, queue create/update reject a mismatch with a clear 400 (so API-created rows can't sneak in), and the scheduler holds back pre-existing mismatched rows with an actionable waiting reason instead of dispatching them — fix the target via edit and the job flows again. - **Manual jog could drive an axis past its travel limit into a collision (#2579, reporter @R3play210)** — Jog the bed up from Bambuddy and, instead of stopping at the travel limit, it keeps going until the nozzle hits the plate; X/Y overrun too, on every model. Instrumenting the exact bytes sent to an H2D showed Bambuddy issuing a clean, correct move at the limit — `G91` / `G1 Z-1.00 F600` / `G90`, no endstop manipulation — that the printer executed straight past the stop, while the machine's **own touchscreen refuses the identical motion**. **This is a Bambu firmware bug: the firmware does not enforce its soft endstops on G-code received over MQTT** (the path every remote tool, Bambuddy included, must use), and it reports no axis position, so Bambuddy cannot know where the bed is to stop it either. It is not fixable from our side. Two things change here: (1) the jog no longer wraps moves in `M211 S0`/`S1` — the old code disabled the firmware's soft endstops *globally* around every jog, which also broke the **touchscreen's** limits until the printer was power-cycled; it now sends a bare move and never touches `M211`, so the touchscreen stays protected. (2) The jog panel now shows a prominent warning that travel limits are **not** enforced during manual moves because of this firmware bug, so nobody trusts the control to stop at the limit. Client-side travel-limit enforcement (dead-reckoning from a home) is tracked separately as the only real mitigation. If your printer currently overruns even from its touchscreen, power-cycle it once to restore the endstops an older Bambuddy build disabled. - **External spool kept its old inventory filament after the type was changed on the printer (#2575, reporter @ajbastien)** — Assigning a new filament to the external spool (e.g. generic ABS in place of generic TPU) left the previous inventory spool assigned, so an ABS spool stayed mapped to TPU. The reconciliation that unlinks a stale external-spool assignment lives in `on_ams_change`, but that callback only fired on changes to the regular AMS units — its change-hash never included the external spool (`vt_tray`/`vir_slot`), and the external-spool data is stored after the AMS handler runs. External-spool identity changes (type, colour, tag, or a reset to empty) now re-trigger the callback so the stale assignment is unlinked; the fill-percentage (`remain`) is deliberately excluded from the fingerprint so a running print doesn't fire it on every push. Follow-up: the auto-unlink now also broadcasts `spool_assignment_changed` for each cleared slot — previously only the manual assign/unassign endpoints did, so an open browser kept rendering the now-unlinked spool on the slot until an unrelated refetch, which read as "the fix didn't work" even though the server state was already correct (reporter confirmed a browser refresh showed the right state all along). - **Two or three users opening the UI at once exhausted the PostgreSQL connection pool immediately (#2572, reporter @Jostxxl)** — Even after the session-hygiene fixes below, the reporter's 93-printer farm saturated the pool the moment a couple of clients logged in together: the log filled with `QueuePool limit of size 10 overflow 20 reached, connection timed out` from `permission_checker` / `is_jti_revoked` / `is_auth_enabled`, and every one of the 30 stuck sessions was `idle in transaction` with the same last statement — the `auth_enabled` settings `SELECT`. Three things had regressed on `dev` after an earlier configurable-pool change was reverted and never re-landed (only the route-by-route session fixes were). **(a) The pool was back to a hard-coded, farm-hostile size.** PostgreSQL ran on `pool_size=10 + max_overflow=20` (30 connections total) with no way to raise it; the `DB_POOL_SIZE` / `DB_MAX_OVERFLOW` / `DB_POOL_TIMEOUT` / `DB_POOL_RECYCLE` env knobs and the `GET /api/v1/system/db-pool` gauge were gone. The PostgreSQL default is again `20 + 80` (100) with `pool_pre_ping` and a 1800s `pool_recycle`, all env-overridable, and `/system/db-pool` is back (it reports resolved config + live checked-out/checked-in/overflow without itself checking out a connection, so it stays truthful under saturation). SQLite is unchanged at `20 + 200`. **(b) Every protected request re-queried `auth_enabled` from the database.** That per-request round-trip — the exact `SELECT` seen on all 30 stuck sessions — is back to being cached for 30s. The cache is deliberately one-directional: only an *enabled* result is ever cached, so a stale read can only make a request *require* auth that a moment ago didn't — it can never skip a check that is now required (staleness fails closed). Toggling auth invalidates it immediately; the TTL is only a backstop for out-of-band changes. **(c) Every authenticated request checked out two pooled connections, not one.** The permission dependencies already hold a session, but the revoked-`jti` check opened a *second* `async_session` on top of it — so a burst of concurrent logins (the SPA fires many protected endpoints at once) needed twice the connections it should. `is_jti_revoked` now accepts and reuses the caller's session; the two token dependencies that check the jti before they have a session open were restructured to open one first, so each authenticated request makes a single checkout. Covered by tests for the dialect-aware pool sizing + env overrides, the pool-status shape, the True-only fail-closed cache (enabled cached, disabled/unconfigured never cached, DB error propagates), and the jti check reusing a provided session versus opening its own. - **The file-manager, storage, camera-snapshot and timelapse routes still held a DB connection across their FTP/camera work (#2572, reporter @Jostxxl)** — After the earlier #2572 fixes the farm still bled connections over a long run — the pool crept from its normal ~14 to the full 300 across ~23 hours (with only ~20 of 93 printers powered on) and then threw `QueuePool limit … connection timed out`. These were the remaining routes of the same class: each took its printer row via `Depends(get_db)`, whose session stays open for the whole request, and then talked FTP to the printer — a listing, a multi-MB download, a delete, a storage probe — with a browser polling the cover/snapshot tiles for every card, offline ones included, and 73 unreachable printers each burning a full FTP timeout. The printer-files endpoints (`/files`, `/files/download`, `/files/gcode`, `/files/plates`, `/files/plate-thumbnail`, `/files/download-zip`, `DELETE /files`, `/storage`), the camera **snapshot** endpoint (sibling of the already-fixed stream), and the timelapse **scan** and **select** endpoints now read what they need in a short session, release the connection *before* the FTP/camera work (`expire_on_commit=False` keeps the loaded `printer.*` columns readable), and — for timelapse, which also writes — re-open a fresh short session only to attach the downloaded file. Behaviour is unchanged; the timelapse-scan boundary is pinned by a regression test that mocks the FTP listing/download and asserts both the detached-row reads and that the attach persists through the fresh session. Completes the route-by-route half of the #2572 effort (camera stream, cover, on_print_start, timelapse scan, finish photo, notification snapshots). - **Four async FTP helpers had no overall timeout, so a saturated FTP thread-pool could pin a caller — and any DB connection it held — indefinitely (#2572, reporter @Jostxxl)** — FTP runs in a fixed 48-worker thread pool. `download_file_try_paths_async`, `download_file_bytes_async`, `get_storage_info_async` and `delete_file_async` wrapped their worker in a bare `run_in_executor` with no `asyncio.wait_for` (unlike `list_files_async`/`download_file_async`, which already had one). The per-socket timeout only bounds a worker once it *starts*; it does nothing for the time a call spends **queued** waiting for a free worker. On a farm where offline printers keep every worker parked on dead connects, that queue wait is unbounded — so an awaiting coroutine, and any pooled DB connection it was still holding, could wait forever. All four now cap the whole operation with `asyncio.wait_for` (returning the same failure sentinel on expiry, the orphaned worker's result discarded), so a backed-up FTP pool can no longer pin a caller — defence-in-depth beneath the route fixes above. - **A wedged SMTP server could freeze the entire event loop during an email notification (#2572, reporter @Jostxxl)** — `_send_email` ran `smtplib` **synchronously on the event loop** and constructed the connection with **no timeout** (smtplib then falls back to the global socket timeout, which the app never sets). A relay that accepts the TCP connection but stalls on the greeting/login/DATA left the send blocked forever — and because it ran inline, it stalled every other coroutine with it. The send now runs off the loop (`asyncio.to_thread`) with an explicit 30s connect timeout, and `quit()` moved into a `finally` so a mid-send error can't leak the socket. Latent bug surfaced while auditing #2572; it presents as a stall/latency spike rather than the pool leak, but the same "blocking I/O on the loop" family. - **The API didn't start serving for ~100 seconds on a large farm while it connected to printers one at a time (#2572, reporter @Jostxxl)** — On the reporter's 93-printer farm port 8000 didn't respond until roughly 100 seconds after the service started. The cause was in the FastAPI lifespan: `init_printer_connections` looped over every active printer and `await`ed each connection *serially*, and each `connect_printer` ends in a fixed one-second settle wait. The MQTT connect itself is non-blocking — `BambuMQTTClient.connect()` only calls `connect_async()` + `loop_start()`, so the handshake runs on a background thread — which means that one-second wait, times the fleet size, was pure serial dead air that the lifespan blocked on *before* the ASGI server began accepting requests. The connections are now started concurrently with `asyncio.gather`, so the whole step takes about a second regardless of how many printers you run, and the dashboard is reachable almost immediately. Each connection's result is also isolated (`return_exceptions=True`): a single unreachable printer no longer aborts the rest — or, as the old un-guarded serial `await` allowed, the entire startup. The MQTT clients still connect in the background exactly as before; only the startup wait is parallelized. - **The print-start handler held a DB connection open across plate detection and the 3MF download (#2572, reporter @Jostxxl)** — After farm-testing the first round of #2572 fixes the reporter still saw `idle in transaction` sessions lasting minutes, and traced one to `on_print_start`: its last statement was `SELECT print_archives…`, immediately followed in the log by the printer's own `on_print_start` → `Trying filenames` → FTP work. The handler opened a single database session at the top and held it to the very end of the function — across two slow I/O blocks that need no database: the optional plate-detection camera capture (a 2.5s chamber-light settle plus an FTP/RTSP grab) and, on the new-archive path, the 3MF FTP download itself (up to five remote paths per candidate filename, each with retry/backoff — the code's own comments cite worst cases of tens of minutes under FTP contention). So one pooled connection sat idle-in-transaction for the whole of both, once per starting print, and print starts cluster on a farm. The connection is now released at both boundaries: reaching either point, only read `SELECT`s have run on that path (every write branch returns earlier), so a commit persists nothing and simply ends the read transaction, returning the connection to the pool for the duration of the I/O; the next query re-acquires a fresh one, and `expire_on_commit=False` keeps the already-loaded `printer.*` columns readable with no lazy load. Behaviour is unchanged. Continues the #2572 effort (camera stream, timelapse scan, finish photo, notification snapshots) to stop holding sessions across slow I/O. - **The printer-cover endpoint held a DB connection open across the FTP thumbnail download (#2572, reporter @Jostxxl)** — The reporter's second correlation: a transaction whose last statement was `SELECT printers…`, matched in the log to the cover route (`Cover: resolved plate …` / `Trying to download cover … (trying 4 paths)`), still open more than three and a half minutes later. `GET /printers/{id}/cover` took its printer row via `Depends(get_db)`, and `get_db` is a `yield` dependency — its session stays open for the whole request, including the cover's 3MF download (up to eight remote paths × retries with backoff, minutes under the same single-FTP-socket contention that produces the 425s). The session was used for exactly one `SELECT`; everything after reads already-loaded `printer.*` scalars, `printer_manager`, and FTP/zip — no database. The endpoint now fetches the printer in a short-lived session and releases the connection *before* the download (`expire_on_commit=False` keeps the columns readable), mirroring the camera-stream fix. Pinned by a regression test that fails if a `get_db`-held session is ever re-added to the route. - **Queue polling re-parsed every 3MF from scratch on each poll (#2573, reporter @Jostxxl)** — The Queue page polls `GET /api/v1/queue/` every few seconds, and for each item with a `plate_id` the serializer called three separate helpers — `extract_print_time_from_3mf`, `extract_filament_usage_from_3mf`, `extract_bed_type_from_3mf` — each of which independently opened the item's ZIP and re-parsed `Metadata/slice_info.config`. With 22 queued items that is 66 ZIP-open + XML-parse operations per poll, run in the event-loop thread, repeated for *every* connected browser even though the files never changed. The three values now come from a single combined parse (`extract_plate_metadata_from_3mf`) cached by file revision — the key is `(path, plate_id, mtime_ns, size)`, so an unchanged file is parsed at most once and a replaced or edited file transparently re-parses with no manual invalidation. The three legacy helpers still exist (other callers use them) but now delegate to the same cached parse, so usage-tracking and Spoolman paths benefit too; the queue hot path calls the combined helper once per row. The cache is a bounded (512-entry) LRU guarded by a lock so it stays small and is safe from worker threads. Listing an unchanged queue now serializes DB data and does no repeat 3MF parsing. (The reporter's broader farm-scale asks — a WebSocket-delta queue, an initial snapshot endpoint, ETag/304 support, per-row plate-request batching — are a separate queue-page redesign, not part of this fix.) - **Progress-milestone and HMS-error notifications held a DB connection across the camera snapshot (#2572, reporter @Jostxxl)** — Both notification paths inside `on_printer_status_change` (the 25/50/75% milestone push and the new-HMS-error push) opened a database session, then captured a camera snapshot for the notification image — an up-to-15s RTSP grab — and sent the notification, all with the session held. So a pooled connection sat idle for the whole grab, per milestone/error, per printer; on a farm those fire constantly. The snapshot needs no database, so it now runs between two short sessions: one to read the printer name, then the grab with no connection held, then a fresh session for the notification send. Behaviour is unchanged; pinned by a test that fails if the snapshot ever runs while a session is open. The AMS-change notification path was left as-is for now (it holds a per-printer lock across its write and needs separate care). Continues the #2572 effort (camera stream, timelapse scan, finish photo). - **Finish-photo capture held a DB connection open across the whole camera grab (#2572, reporter @Jostxxl)** — When a print finishes, the background finish-photo task reads a couple of rows (the capture setting, the printer, the archive) and then runs a capture pipeline that can take tens of seconds — timelapse last-frame extraction, waiting up to 20s for the stage-22 producer, an external-camera HTTP grab, or a fresh RTSP shot. It held one database session open across that entire pipeline, so a pooled connection sat `idle in transaction` for the full capture, once per finishing print — and finishes cluster on a farm. It now reads what it needs in a short session, releases the connection, runs the capture with no session held, and re-opens a fresh short session only to append the photo to the archive. Behaviour is unchanged. Continues the #2572 effort (camera stream, timelapse scan) to stop holding sessions across slow I/O. - **Timelapse scan held a DB connection open across every FTP round-trip (#2572, reporter @Jostxxl)** — After a print completes, `_scan_for_timelapse_with_retries` polls the printer's FTP server for the new timelapse file (up to 4 retry attempts, plus a name-match fallback). Each attempt opened one database session and held it across the FTP directory listing *and* the multi-MB video download — so a pooled connection sat `idle in transaction` for the whole transfer, once per attempt, per completed print. When several prints finish together on a farm that adds up. The scan now reads the archive + printer in a short session, releases the connection, does the FTP list/download with no session held, and re-opens a fresh short session only to attach the downloaded file. Behaviour is unchanged; the existing scan tests already exercise the read→download→attach path. Continues the #2572 effort (after the camera-stream fix) to stop holding sessions across slow I/O; the scheduler paths were reviewed and found already bounded (single loop + capped concurrent uploads, with an explicit pre-dispatch commit) so they were left as-is. - **Live camera stream held a database connection open for its entire duration (#2572, reporter @Jostxxl)** — The `/camera/stream` MJPEG endpoint took its printer row via `Depends(get_db)`, but `get_db` is a `yield` dependency: its session isn't released until the response body finishes streaming, which for a live stream is however long the browser tab stays open — minutes to hours. On a large farm every open camera tile therefore pinned one pooled DB connection `idle in transaction`, so a wall of dashboards could drain the pool on its own (a top contributor to the exhaustion in #2572). The endpoint now fetches the printer in a short-lived session and releases the connection *before* it starts streaming (`expire_on_commit=False` keeps the already-loaded columns readable). Pinned by a regression test that fails if a `get_db`-held session is ever re-added to the route. Part of the broader effort to stop holding sessions across slow MQTT/FTP/camera/3MF work. - **PostgreSQL connection-pool exhaustion on large printer farms (#2572, reporter @Jostxxl)** — On a ~93-printer farm the SQLAlchemy pool (hard-coded `pool_size=10` + `max_overflow=20` = 30 connections) was repeatedly saturated with all connections `idle in transaction`; unrelated API requests then waited out the 30-second pool timeout or failed in the auth middleware, and an unauthenticated `/api/v1/printers` probe took ~25s to return 401. Three things fed the pressure: the pool was fixed and not configurable; every authenticated request re-queried `auth_enabled` from the DB (the middleware alone opened a session per request just to probe it); and the pool was small for a farm. This change (a) makes pool sizing configurable via `DB_POOL_SIZE` / `DB_MAX_OVERFLOW` / `DB_POOL_TIMEOUT` / `DB_POOL_RECYCLE` env vars and raises the PostgreSQL default to `20` + `80` (100 total) with `pool_pre_ping` and a 1800s `pool_recycle`; (b) caches the `auth_enabled` probe for 30s — only the *enabled* result is ever cached, so a stale read can only ever fail closed (require auth), never open, and any toggle invalidates it immediately; and (c) adds a `GET /api/v1/system/db-pool` diagnostic exposing the resolved config plus live `checked_out` / `checked_in` / `overflow` gauges (read without checking out a connection, so it stays truthful under saturation). Note: connections being held across slow MQTT/FTP/camera/3MF work — the underlying reason transactions sit idle — is a deeper session-hygiene change tracked separately; this drop relieves and instruments the problem and makes the farm sizing configurable. See the PostgreSQL wiki page for large-farm tuning and the required `max_connections` headroom. - **P1S camera still black on every page load, recovering only after ~20 minutes (#2521, reporter @nnimby848)** — The previous round of fixes did not take, and the reporter re-tested on two daily builds to say so. The fan-out barrier added last time — a replacement stream waits for the displaced one's socket to close before dialling, so a printer that allows a single camera connection never sees two at once — was correct, and was being **bypassed**. `shutdown_broadcaster()` *popped* the broadcaster out of the registry and only then awaited its teardown, so for the duration of the socket close the registry slot sat empty. A `/camera/stream` request landing in that window found nothing, minted a broadcaster with no predecessor to wait for, and dialled port 6000 immediately. The barrier only engages when the displaced broadcaster is still findable — and the one path that tears a stream down on purpose removed it first, disabling the barrier in exactly the case it was written for. A page reload fires `/camera/stop` and the new `/camera/stream` **concurrently**, which is why it reproduced on essentially every load. The printer then held two connections, kept feeding the orphan, and starved the live viewer: the new socket connects (the reporter's logs show `Chamber image: connected`) and then receives nothing until the printer's TCP keepalive reaps the dead one — **his 20 minutes, to the minute**. The stopped broadcaster now stays in the registry so the next viewer chains behind its socket close, which is what the barrier always intended. Pinned by a test that counts *actual* sockets through the real stop-then-restream race and fails with `2` against the old code; the existing barrier tests placed the broadcaster into the registry by hand, which is precisely why they never caught this. - **Every camera page load attached two viewers and abandoned one (#2521)** — Found while reproducing the above, and the reason it fired on *every* load rather than occasionally. The stream-token query runs whether or not authentication is enabled, and the camera page subscribes to it: the first render produced an `` with no token, the token arrived, and the re-render **changed the src**. The browser aborts the in-flight request and issues a second one — and with auth disabled no token is required, so *both* reached the backend and attached to the fan-out. The reporter's HAR shows it exactly: two requests to the same stream URL, same cache-buster, one without `token=` and one with. His backend log shows the consequence, `subscribers=2`, on a printer that allows one connection. The src is now rendered only once the token query has settled — one URL, one request, one viewer — and an auth-disabled install whose token endpoint fails still streams, because it never needed a token. - **A viewer that left during a black stream stayed counted for 30 seconds (#2521)** — Also found on the way. A subscriber only checked whether its client was still connected *after* it had yielded a frame, or when a 30-second idle timeout fired. So a browser that walked away while the stream was producing nothing — the exact situation above — went on being counted as an attached viewer for up to half a minute. That matters beyond tidiness: `/camera/stop` consults the subscriber count to decide whether to tear the upstream down, so a phantom viewer could make it skip the teardown entirely and leave the socket open. Disconnects are now noticed within a second even when no frames are flowing. - **"Please login." when importing from MakerWorld — while Bambuddy said you were connected to Bambu Cloud** — An expired Bambu Cloud token was indistinguishable from a working one, so the UI reported "Connected as ..." indefinitely while every cloud call was being rejected. The toast you got was Bambu Lab's own words, forwarded verbatim: their 401 body is `{"code":4,"error":"Please login.","message":""}`, and we passed the `error` field straight through — which read as Bambuddy telling you to log in, next to an indicator saying you already were. **The status was never real.** `set_token()` stamped `token_expiry = now + 30 days` *every time a stored token was loaded from the database* — re-derived from the current moment, for a token of entirely unknown age — and `is_authenticated` was "we have a string, and we're not past that expiry". The expiry reset on every request, so the check could never fail. It was a string-presence test wearing an expiry costume, and `/cloud/status` answered `true` for as long as any token existed. Bambu's access token is opaque (no readable claims), Bambu's login response carries no expiry, and Bambuddy discards the `refreshToken` it is handed, so nothing else in the system knew either. When a token lapsed — Bambu's own comment in our code says they last around three months — **every** cloud feature died at once (MakerWorld imports, cloud profiles, slicer presets, firmware checks) with no signal anywhere that a re-login was needed. **Bambu is now the authority.** No expiry is invented. `/cloud/status` asks Bambu whether the token is still accepted, cached for five minutes so the several components polling it don't each pay a round-trip, and any 401 from any authenticated cloud call durably records the credential as dead — so the whole app agrees at once instead of each feature failing separately. An unreachable Bambu, a 5xx, or a Cloudflare challenge is treated as *unknown*, never as *expired*: an outage must not sign a working session out. The Profiles page now explains why the login form is back, MakerWorld says the sign-in expired rather than that one is required, and its import buttons stop pretending they can download. The user-facing message names the **Profiles** page, where the Bambu Cloud sign-in actually lives — the old fallback text pointed at "Settings → Bambu Cloud", which does not exist. - **Importing from MakerWorld failed on Windows with a certificate error (#2562)** — Paste a MakerWorld URL, click Save, and the import dies with `S3 download failed: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate`. Only native Windows installs are affected; Docker never sees it. The import walks several hosts, and the failure is at the last hop: Bambu Cloud answers the download request with an **AWS presigned URL**, and that one URL is fetched with `urllib` rather than httpx, on purpose — S3 signs the exact query-string bytes, and httpx re-encodes them into a `SignatureDoesNotMatch`. What that swap quietly also changed was the **trust store**. httpx — every other network call in Bambuddy, including the `api.bambulab.com` calls that succeed immediately before this one — verifies against the bundled `certifi` CA bundle. `urllib` verifies against the *operating system's* store, and on Windows the two disagree: Python's `ssl.load_default_certs()` only enumerates the roots already cached in the Windows ROOT store, which Windows fills in lazily through CryptoAPI's auto-update — a mechanism Python never triggers. On a machine where the Amazon root signing the S3 chain has not been cached yet, verification fails with exactly the error above. Linux images ship a complete `ca-certificates` bundle, so the OS store and certifi agree and the bug is invisible there. The S3 hop now verifies against certifi too, so it trusts precisely what the rest of the app already trusts. Verification itself is untouched — the certificate is still checked and the hostname still matched; the fix changes where the CA list comes from, not whether TLS is enforced. The URL still reaches the transport byte-for-byte, so the S3 signature is unaffected, and the no-redirect guard that keeps the download-host allowlist meaningful is unchanged. `certifi` is now an explicit requirement rather than one inherited from httpx, so a future httpx release cannot drop it out from under this import. - **Prints on a multi-printer farm started one by one, up to an hour apart (#2555, reporter @Maxtrim3D)** — Start a batch across several printers and they trickle out one at a time; the more printers, the worse it gets. Not a misconfiguration, and nothing in the wiki could have helped: the scheduler awaited each dispatch inline in its selection loop, and a dispatch includes the FTP upload. So every printer queued behind every other printer's transfer, even though they are entirely independent machines. **The arithmetic is the whole bug report.** A Bambu printer's FTP server sustains around 150 KB/s — its own SD-card write is the bottleneck, not the network — so the reporter's 41 MB `.3mf` took **254 seconds per printer**, straight from his logs (`40978500 bytes in 254.1s, 157 KB/s`). Nineteen printers in series is roughly **80 minutes** before the last one starts, which is exactly the "up to 1 hour" he reported, and exactly why it got worse the more printers he selected — the delay is linear in fleet size. The logs show the next upload beginning **131 ms** after the previous one finished, back to back, forever. **Uploads to different printers now run concurrently**, capped by a new **Settings → Workflow → Queue & Dispatch → Concurrent Uploads** value (default 4, up to 16; set it to 1 for the old strictly-serial behaviour if your network or host cannot take parallel transfers). Selection is unchanged and still sequential — only the transfers overlap — so every existing gate (busy printers, plate-clear, filament deficit, shortest-job-first, staggered start) behaves exactly as before, and a queue pass still finishes all of its uploads before the next one begins, which is what stops the same still-`pending` row being dispatched twice. FTP work also moves off asyncio's shared default executor onto its own pool: that executor is sized `min(32, cpu_count + 4)` — six threads on a 2-core NAS — and is shared with everything else in the app, so parallel uploads would have parked one thread each, for minutes at a time, and starved unrelated work. - **A printer that accepted a file but never started printing was retried forever (#2555)** — Surfaced by the same reporter: "I have a printer who, since the morning, still not launch." When a printer takes the file (its `subtask_id` advances) but never actually begins, the start-watchdog waits 270 seconds, reverts the queue item to `pending`, and the next pass re-uploads the entire file and waits it out again — with **no attempt limit**. For a genuinely wedged printer that loop never terminates, and on a farm each lap also consumes an upload slot the other printers are queueing for, so one stuck machine dragged out everybody else's start times. Retrying is right; retrying forever is not. Attempts are now counted on the queue item: the transient causes the watchdog already recovers from (a publish lost on a half-broken MQTT session is fixed by the forced reconnect on the very next try) still get their retries, but after **three** the item is failed with a message pointing at the printer — check its screen for a prompt or error, and check the SD card — rather than being handed back to the queue a fourth time. - **A queued library print with no readable print time crashed the dispatch — and took the rest of that queue pass down with it (#2555)** — Found while reviewing the above. Starting a print from a library file read `library_file.print_time_seconds`, a column `LibraryFile` does not have (its print time lives in the file's parsed metadata). It only fired when the archive carried no print time of its own — a plain `.gcode`, or a 3MF the parser could not read — and it fired *after* the job had already been sent to the printer, so the print itself ran but the "print started" notification was lost. Worse, the error unwound the whole queue pass: every other printer still waiting to be dispatched on that tick silently missed its turn and had to wait for the next one. It now uses the print time the queue item already caches. The concurrent-dispatch change above independently contains this class of failure — one printer's dispatch blowing up can no longer cancel its siblings' in-flight uploads. - **A print mapped to a different filament than it was sliced for was logged under the sliced material, not the one actually used (#2563, reporter @alexfilimon)** — Slice a model for **Bambu PLA Basic**, open Filament Mapping in the Print dialog, and — because no PLA was loaded — hand-pick the only loaded **PETG** slot. The printer prints from PETG, the PETG spool is correctly debited, but the Archive card, the Print Log and the material statistics all still call the run **PLA**. So "filament used", the one label that should describe what left the spool, described what the slicer asked for instead. The archive's `filament_type` is stamped once from the 3MF at creation and never revisited; the Print Log copies it verbatim at completion and the stats group on it. The material Bambuddy actually consumed was known all along — usage tracking resolves every used slot to the spool that fed it and already carries that spool's `material` — it just wasn't being written back. This is the exact problem that was solved for filament **colour** a while ago (#1494): once usage tracking has matched every used slot to an inventory spool, the spool's curated colour replaces the slicer's, so an archive printed from a `#000000` spool stops showing the slicer's near-black. Material now does the same. When every slot with non-zero usage resolves to a spool that declares a material, the archive's `filament_type` is rewritten from those spools — slot-ordered, de-duplicated, comma-joined exactly like the colour and the original type — and because that rewrite is committed before the Print Log entry is written, the corrected material flows through to the archive card, the Print Log and the stats with no further work. **All-or-nothing, deliberately**, mirroring the colour path: if even one used slot can't be resolved to a spool with a material, nothing is rewritten, so a partial match can never drop a slot's type from the archive or the material graph. A run whose mapping matched the slice is a no-op (the rewrite equals what's already there). **Both inventory backends, same drop.** The built-in Spool inventory does it from the matched spools' `material`; Spoolman does it from the resolved Spoolman spool's `filament.material`, captured at the same point the spool is already fetched for its colour, so no extra Spoolman round-trips. The remain%-delta fallback (no-3MF "Untitled" prints) intentionally sits it out in both backends, exactly as it does for colour — those prints have no 3MF slot to attribute a material to. **Tests.** 7 on the internal helper (the reporter's PLA-slice-to-PETG-spool case; slot-ordered de-dup across a multi-material print; the all-or-nothing gate leaving a partially-matched print untouched; a zero-usage slot needing no spool; no-used-slots and slot_id-less fallback results both declining to rewrite; a blank material not counting as a match). 3 on the Spoolman archive rewrite against a real DB session (a PETG spool overwrites a PLA slice; a partial match leaves `PLA,PLA` untouched; an empty material map is a no-op). Existing usage-tracker and Spoolman suites unchanged and green. - **Every job on a busy farm waited up to 30 seconds after a printer freed up before it was sent (#2555, reporter @Maxtrim3D)** — With the parallel-upload fix in, the reporter still saw prints take "several long minutes" to leave the queue, sometimes going out together and sometimes in dribs. The scheduler's main loop did its work and then slept a **fixed 30 seconds** before looking again, unconditionally. That interval is dead air: a printer that finished a job one second after a pass ended sat idle for the next 29 before its follow-on print was even considered, and a batch fanning out across a fleet — where printers free up a few seconds apart as their current jobs end — dispatched in 30-second steps regardless of how fast the machines were actually becoming available. On nineteen printers that is minutes of nobody-is-uploading time stacked on top of the transfers. The loop now **re-checks within a few seconds whenever the previous pass actually dispatched something**, and only falls back to the 30-second idle sleep when a pass sent nothing. So a draining batch keeps moving at the speed the printers free up, not at the speed of a fixed timer. This cannot become a busy-loop: the fast tick fires *only* after a productive pass, and a pass is productive only while there is ready work to send — the moment the remaining items are all behind printers that are genuinely busy printing (or behind a wedged head-of-line job holding its printer in the post-dispatch cooldown), the pass dispatches nothing and the loop reverts to the slow interval. Selection, the concurrency cap, and the finish-all-uploads-before-the-next-pass invariant are all untouched; only the gap between passes shrinks when shrinking it helps. **Tests.** 2 new cases: a pass that dispatches three items reports that it did (so the caller re-ticks fast), and an empty queue reports that it did not (so it sleeps normally). The existing concurrent-dispatch suite — parallel fan-out, the cap, the serial escape hatch, one-failure-doesn't-cancel-siblings, and the uploads-finish-before-return invariant — passes unchanged against the new return value. - **Debug logging was unusable on a large fleet, and the support bundle only shipped a fraction of what was on disk (#2555)** — We asked the reporter to turn on debug logging and send a support bundle. The bundle came back holding **4 minutes 49 seconds** of history — barely one upload — for a problem that takes an hour to unfold. Two causes, both fixed. The state dumps in the MQTT push_status handler fired whenever their field was *present* in a frame, and a full frame carries every field, so they fired on **every frame** regardless of whether anything had changed; several said "updated" or "when X changes" in their own comment while doing nothing of the sort. On one printer that is ~1.5 lines/s and invisible. On nineteen it is ~100 lines/s: **27,727 of the bundle's 29,830 lines** were these dumps, and they rolled the 5 MB log over in under five minutes. They now log transitions only — every change is still recorded, the steady-state repetition is not. Separately, the bundle shipped only the live `bambuddy.log` and ignored the three rotated backups sitting next to it, even though its own byte budget was four times larger than the file it was reading; it now spans the rotation, oldest first, spending the budget on the most recent history. - **Filament Override vanished for a multi-plate selection in Any [model] mode — but only on the second visit (#2552, reporter @bondjw07)** — Open a sliced multi-plate `.gcode.3mf`, pick **Any [model]**, tick two plates, and the whole Filament Override section is gone. Tick one plate and it comes back. The reporter tied it to having queued or printed the file before, which is the real clue, but not the cause: what actually mattered was that the dialog had been opened once already, so the plates data was still in the cache. The filament requirements are fetched under a key that carries the selected plate, and that key is `null` as soon as two plates are ticked. On the first open the plates are not yet known, so for one render the modal cannot tell it is a multi-plate file and fetches the requirements for the whole file — the union of every plate's filaments — which the override panel then rendered from. On the next open the plates are already cached, the modal knows it is multi-plate from the first render, the whole-file fetch therefore never happens, and the panel had nothing to render. So the section's visibility was decided by a cache race, and the case that "worked" was showing you filaments from plates you had not selected. **Both halves are now wrong-free**: a multi-plate selection in model mode renders one **Filament Override — Plate N** panel per selected plate, each fetched for that plate and listing only the slots that plate actually prints, identical on a cold and a warm cache. A slot's chosen filament and its Force color match tick are shared across plates that print that slot — slot ids are global to the file, so slot 3 is the same filament wherever it appears — and each queued plate is sent only the overrides for its own slots, so a colour forced for plate 2 no longer holds plate 1 back (the API narrows them per plate as of #2551, and the modal no longer sends them wide in the first place). Measured on the old code: warm cache, two plates → zero override panels; cold cache → one panel listing both plates' filaments. Now: two panels, one slot each, either way. **Four further holes in the same per-plate machinery closed while reviewing it**: a manual tray pick on one plate survived a change of printer, and a global tray id names a different spool on a different machine — so the job went out on a tray nobody chose; a plate whose filaments could not be read (or had simply not loaded yet) was indistinguishable from a plate needing none, and was queued with no mapping and no forced colours, to print in whatever happened to be loaded — the Print button now waits for every selected plate to answer and says which one could not be read; the "not enough filament left" check still weighed the whole file's filaments against a mapping the plates no longer use, so it either failed to warn at all or warned about trays the print would not touch — it now follows what each plate actually dispatches, and sums the demand per tray, because 60 g left does not cover two plates of 40 g even though it covers either one of them; and the per-printer tray editor still appeared for a multi-plate fan-out, collecting tray choices that were then discarded. - **Queueing several plates of one file mapped them all through the first plate's filaments — and hid the panel that would have shown you (#2551, reporter @bondjw07)** — Select one plate and the Filament Mapping panel appears; select a second and it vanishes, and in **Any [model]** mode it never appears at all. Both were deliberate, and one of them was covering a wrong-tray dispatch. **Why the panel hid.** It maps one set of 3MF slots onto one printer's AMS trays, so it needed a single plate and a single printer; `selectedPlates.size <= 1` hid it the moment you ticked a second plate. In model mode there is no printer selected, so there are no trays to map onto — that one is legitimate, and the scheduler computes the mapping per plate when it picks the printer. **What the hidden panel was hiding.** The modal kept posting an `ams_mapping` anyway. With two plates selected the modal has no single plate to ask about, so it falls back to the whole file's filament list — the **union of every plate** — and matched against that. Tray assignment is stateful: a tray claimed by one slot is not offered to the next. So for a file where plate 1 prints red on slot 1 and plate 2 prints red on slot 2, slot 1 took the only red spool and slot 2 fell through to a type-only match on **black** — and that one mapping, `[red, black]`, was sent with *both* plates. The scheduler uses a stored mapping verbatim and only computes its own when the item has none, so plate 2 printed in the wrong colour, decided by a panel the user was never shown. Measured, not deduced: driving the old modal with a real cache posts `ams_mapping: [0, 1]` for both plates. (It reproduces only with a realistic React Query cache — the test harness's `gcTime: 0` evicts the union and makes the modal look innocent, which is why this hid for so long.) **Now each plate maps itself.** Select several plates on one printer and you get one mapping panel **per plate**, named after it, each showing and mapping only the slots its own plate prints, each with its own tray overrides — pin plate 2's red to a different spool and plate 1 is untouched. Each queue item carries its own plate's mapping. Fanning several plates across several printers would be a panel per plate per printer; those items are queued with **no** mapping instead, and the scheduler maps each plate against the printer it actually dispatches to, which it already does correctly. **One matcher, not three.** The tray-matching logic existed twice (once in the hook, once in `computeAmsMapping`) and this needed a third caller, so it is now extracted once and both paths delegate to it — the per-plate panel and the per-printer fan-out cannot drift apart. Its 62 existing tests pass against the extraction unchanged. **Tests.** 3 on the matcher, pinning the exact divergence: each plate alone maps to the red tray, the union starves the second slot onto black, and a manual override on one plate does not leak into another. 4 on the modal: one panel per selected plate; each plate posts the mapping for its own slots (`[0]` and `[-1, 0]`, not the union's `[0, 1]`); a multi-printer fan-out posts none; a model-assigned job posts none. Mutation-verified against a production-like cache — the per-plate test fails with exactly the old `[0, 1]`, and removing the multi-printer guard leaks printer 1's trays onto printer 2. - **Queueing several plates of one file with Force color match made every plate wait for every colour (#2551, reporter @bondjw07)** — A sliced multi-plate `.gcode.3mf`, each plate a single different PLA colour, queued to **Any X1C** with **Force color match** on. A printer with Army Blue loaded and idle should take the Army Blue plate. Instead every plate sat at Waiting on `PLA (Army Blue), PLA (Ash Grey), PLA (Sunshine Yellow)` — the colours of *all* the plates. Queue the same plates one at a time and it works, which is the tell. **One override list, handed to every plate.** The print dialog only tracks a selected plate when exactly one is selected; pick several and it asks the backend for the filaments of the *whole file*, which is the union across all plates. It builds its override list from that union — correctly, because the user does need to tick each colour once — and then posts **that same list** with each plate's queue item. A `force_color_match` entry means "do not dispatch until this printer has this exact colour loaded", and the scheduler enforces *all* of them, so each single-colour plate demanded the whole batch's palette. The reporter's own guess in the issue was exactly right. **The API is what fixes it.** The overrides are now narrowed to the slots the item's plate actually prints, at write time, on both create and edit — the backend is where the 3MF is, so this holds for every writer of the queue and not just the one dialog. Nothing changes for a single-plate job or for a whole-file job, where the union *is* the requirement. **A second, quieter version of the same bug.** Override types are merged into the item's `required_filament_types`, which is the gate that runs *before* colours are even considered. A shared list therefore also widened that gate: queue a PLA plate and a PETG plate together and the PLA one would refuse every printer that didn't also have PETG loaded, with no mention of colour anywhere in the reason. Narrowing the overrides closes that too. **Fails strict, never silent.** When the plate's slots can't be established — corrupt 3MF, source file gone — the overrides are kept whole rather than dropped. An item waiting on a colour it doesn't need is visible and fixable in ten seconds; an item that silently lost its forced colour prints in the wrong filament. **The plates already in your queue are repaired on upgrade.** Fixing the write path alone would have left every item queued before this release sitting exactly where it is — stuck, with a waiting reason that explains nothing — until the user worked out for himself that deleting and re-adding them was the cure. A startup migration re-scopes the pending items instead. Items that are already printing or done are left untouched: their overrides are a record of what they dispatched with, not an instruction. **Tests.** 6 cases on the API (each of three plates keeps only its own colour and its own slot id; a whole-file job still keeps all three; an unreadable 3MF keeps all three; a PLA plate's required types stay PLA when a PETG plate is queued alongside it; editing an item narrows its overrides too; moving an item to another plate re-scopes it). 5 on the repair (three stuck items each come back to their own colour; a second boot is a no-op; a printing item is not rewritten; a whole-file item keeps all three; a missing source file strips nothing). Mutation-verified — six of the eleven fail against the old code. The migration was run against a real PostgreSQL 16 as well as SQLite, twice over, to confirm it is dialect-neutral and idempotent. - **A project's tags vanished from the edit dialog when you opened it from the projects list — and its priority was quietly reset when you saved (#2536, reporter @fireboyff)** — Editing a project from the Projects list showed an empty tags field; opening the same project first and editing it from inside showed the tags correctly. **One dialog, two callers.** `ProjectModal` is shared: the detail page hands it a full project, the list hands it a list item. The list endpoint's payload never carried `tags`, `due_date` or `priority`, so from the list the dialog seeded those three fields from `undefined` and rendered them blank. It compiled because the component read them through a cast (`project as ProjectListItem & { tags?: string }`), which asserts a field the type does not have — so TypeScript never pointed out that the value was always missing. The fields are now on `ProjectListResponse` and on `ProjectListItem`, the casts are gone, and the compiler enforces the two shapes agreeing from here on. **The part nobody reported.** The dialog does not send tags when the field is empty, so the tags themselves survived — they were only invisible. Priority is not so lucky: it is *always* sent, defaulting to `normal`. So editing a **high** or **urgent** project from the list silently demoted it, and the reporter would have had no reason to connect that to the empty field he did see. Fixing the payload fixes both, since the dialog now receives the real priority to send back. **Clearing a tag list also never worked, from either view.** An emptied field was sent as `undefined`, which drops the key from the request, and the backend only applied values that were not null — so the old tags came straight back. Tags and due date now behave like budget and URL already did: sent as null, cleared explicitly, and an omitted key still means "leave it alone". **Tests.** 4 backend cases (the list and the template list both carry the fields the dialog renders; a partial update does not disturb a stored priority or tags; an explicit null clears tags and due date) — mutation-verified, three of them fail against the old payload. 3 frontend cases pin the dialog: it prefills all three from a list item, it round-trips a stored `high` instead of submitting its default, and clearing the tags field sends null. The templates list was missing `target_parts_count` too, which the same dialog edits; that is fixed in passing. - **Scheduled backups to a NAS failed with "Read-only file system" — and our own systemd unit was the reason (#2544, reporter @pwostran)** — Nightly backups to a mounted NAS share had run since May and then stopped, failing every night with `[Errno 30] Read-only file system`. The reporter checked the folder permissions, which were correct: his mount is `gid=backup,dir_mode=0775`, the service user is in `backup`, and his own shell writes to the share fine. **Errno 30 is EROFS, and EROFS is not a permission error** — a permission problem is errno 13. EROFS means the filesystem itself refused the write, and the filesystem refused it because *we told it to*. Bambuddy's systemd unit ships `ProtectSystem=strict`, which mounts the entire filesystem read-only inside the service's own mount namespace and carves back out only `ReadWritePaths= `. A NAS share is not one of those three. Reads still work — which is why the UI happily listed his existing backups from the share while being unable to create a new one — and the operator's shell is outside the namespace entirely, so every check he could think to run said the directory was fine. **How a working install broke.** Both installers write `/etc/systemd/system/bambuddy.service` wholesale, so any `ReadWritePaths` an operator had added by hand disappeared on the next install, along with their backups. That is now fixed at the source: the installers back the old unit up (`.bak-`) and **carry the operator's extra writable paths forward** into the new one, reporting which ones they kept. The unit template also documents the carve-out, since the next person to read it has to be able to work out why a directory they can write to is read-only for the service. **The failure is no longer silent, or cryptic.** The output directory is now probed with a real write when you save it and when the backup card loads, so a directory Bambuddy cannot write to is caught there and then rather than at 03:00 for a week. When the probe fails, the card names the actual cause and hands over the exact fix with the operator's own path already in it — `sudo systemctl edit bambuddy` → `[Service]` → `ReadWritePaths=/mnt/nasbackup` — instead of quoting an errno. A failed backup run reports the same diagnosis rather than the raw OSError. EROFS outside systemd, permission-denied, out-of-space, not-a-directory and missing are told apart and worded accordingly, in all 11 locales. **A Docker trap caught on the way past.** A backup path inside the container that was never bind-mounted from the host is *writable* — the write lands in the container's ephemeral layer and vanishes on the next `compose up`. A backup that silently goes nowhere is the one failure mode a backup feature must not have, so the probe compares the directory's device against the container root and warns when they match, with the compose snippet that mounts it properly. **Tests.** 15 backend cases: EROFS under systemd is diagnosed as the sandbox and yields a copy-pasteable drop-in; EROFS *outside* systemd does not blame a unit that doesn't exist; EACCES stays a permission problem; the unit name is read from the cgroup (plain, templated, and the fallback when there's no `.service` in it); the probe leaves no file behind in the backup list; a container-layer path is flagged while a mounted volume is not; a failed run surfaces the diagnosis and not the errno; and four pin the installers, so a reinstall can never again drop a writable path or overwrite a unit without a backup. Verified against a real read-only mount, not a mocked one — the classifier was run against an actual `mount -o ro` tmpfs and returned the reporter's exact errno with the right remedy. 4 frontend cases on the banner. - **Docker never shut down gracefully — every stop, restart and update was a SIGKILL** — `CMD ["sh", "-c", "uvicorn ..."]` left the shell as PID 1 with uvicorn as its child, and dash does not forward signals. So `docker stop` SIGTERMed the shell and **uvicorn never heard about it**. Measured on the shipped image: the stop ran the full 10-second grace period, the container exited **137** (SIGKILL), and the log contained no "Shutting down" line at all — it simply stopped dead after `Uvicorn running on ...`. That means the entire shutdown path had never once executed in Docker: no SQLite WAL checkpoint, no MQTT disconnect (the broker saw an ungraceful drop every time), no virtual-printer teardown, no printer disconnect, no `engine.dispose()`. Not "when a camera is streaming" — *always*, on every `docker stop`, `docker restart`, `compose down` and image update. The fix is one word: `CMD ["sh", "-c", "exec uvicorn ..."]`. With `exec`, uvicorn *is* PID 1 and receives the signal. Verified on a rebuilt image: PID 1 is now `uvicorn`, `docker stop` completes in **1 second** with **exit code 0**, and the log shows `Shutting down` → `WAL checkpoint completed` → `Application shutdown complete`. - **`systemctl restart` could hang for 90 seconds and end in SIGKILL** — with a camera tile open, stopping Bambuddy would sit at `Waiting for connections to close.` until systemd gave up and killed it. Uvicorn's `timeout_graceful_shutdown` defaults to `None`, i.e. **wait forever** for in-flight requests, and an MJPEG camera stream is a response that never completes — `httptools`'s connection `shutdown()` only flips `keep_alive = False` on an in-flight cycle, it never closes the transport. So a single open stream pinned the process. Worse, the ordering is inverted: uvicorn only fires the **lifespan shutdown** — the code that would tear those streams down — *after* the connections drain, so the cleanup that would unblock the wait was itself blocked by the wait. Every launcher now passes `--timeout-graceful-shutdown 5`: the Dockerfile, the shipped `deploy/bambuddy.service`, the systemd unit and launchd plist emitted by `install/install.sh`, the SpoolBuddy installer's unit (a kiosk parked on the printers page holds exactly such a stream open, so this bit it on every reboot), and the Windows NSSM registration. On timeout uvicorn cancels the request tasks and the camera generators unwind cleanly on `CancelledError` — a path they already handled. `TimeoutStopSec` is raised to 30s on the systemd units as a backstop rather than the mechanism, and `stop_grace_period: 30s` added to the compose file so a slow teardown on a Pi isn't clipped. On Windows, NSSM's stop sequence was also force-killing uvicorn mid-teardown: its default `AppStopMethodConsole` is **1500 ms**, far less than uvicorn needs, so that is raised to 15s and the useless WM_CLOSE / thread-message stages (uvicorn is a console app with no window and no message loop) are skipped. **Tests.** 9 cases pinning every launcher — that the Dockerfile `exec`s, that each of the six launch points carries the timeout flag, that the systemd stop timeouts leave room for the teardown, and that NSSM waits long enough for the Ctrl-C. None of this shows up in a functional test: the app is perfectly healthy right up until you ask it to stop. - **Energy Summary stuck at zero for Yesterday and Total on REST smart plugs — and the Statistics energy figure with it (#2539, reporter @R3play210)** — A Shelly Plug S Gen3 wired up over the REST integration showed live power and a Today figure that climbed, but Yesterday and Total never moved off zero, through five days of printing. **The bug.** `RESTSmartPlugService.get_energy()` returned a dict with two keys, `power` and `today`. It never set `yesterday` or `total` at all, so `SmartPlugEnergy` defaulted them to null and the summary card summed nothing. Tasmota returns all three; Home Assistant returns two; REST returned one. **The number that looked right was also wrong.** A Shelly has no notion of "today" — its only energy figure is `aenergy.total`, a lifetime counter in watt-hours that climbs forever and never resets. Bambuddy had a single energy field, so the reporter put the lifetime counter in it, and line 230 filed it under `today`. It *looked* correct because it grows; it just never dropped back to zero at midnight. The one figure he trusted was the least trustworthy of the four. **It broke more than the card.** With `total` never populated, the hourly snapshot recorder skipped the plug outright (its own comment said so: *"REST plugs that only expose today can't be used for cumulative snapshots"*), `_sum_live_plug_totals()` summed zero, and since the reporter's `energy_tracking_mode` is `total`, the **Statistics page's energy figure was zero too** — he simply hadn't got to it yet. **The fix.** A REST plug now says which counter it has: `rest_energy_path` still means "energy used today", and a new `rest_energy_total_path` means "lifetime counter that never resets". A Shelly has only the latter; a Tasmota behind a REST bridge has both; both are read from one HTTP fetch when they share a URL. Then, because the snapshot table already records that lifetime counter hourly, **Today and Yesterday are derived from it**: today = the counter now minus its value at the last local midnight, yesterday = that midnight's value minus the one before. So a Shelly gets all four numbers with no new device capability — and Home Assistant's permanently-null Yesterday is fixed for free. Today appears after the first midnight the install lives through, Yesterday after the second; a counter that goes backwards (factory reset zeroes `aenergy.total`) reports nothing rather than a negative. **Local midnight, not UTC.** With `TZ=Europe/Berlin` a UTC boundary would roll Today over at 02:00 wall-clock. The snapshot loop now ticks on the *local* hour instead of every 3600s from boot, so a reading lands exactly on the day boundary — including in the half-hour-offset zones (India, Nepal) where local midnight isn't on a UTC hour at all. Previously the last snapshot before midnight could be up to an hour early, and an hour of a printer's draw is real watt-hours to lose off the day. **Collateral: the whole smart-plug subsystem was broken on Postgres.** Every `DateTime` column in the smart-plug tables is naive and holds UTC, but the code wrote *aware* datetimes into them. SQLite tolerates that — its bind processor reads the fields and drops the offset — which is why it went unnoticed. asyncpg does not: it raises `DataError: invalid input for query argument`. So on Postgres every energy-snapshot capture raised (silently, inside the loop's `except`), leaving the snapshot table empty and the date-filtered energy stat permanently zero, and every plug status poll raised on `last_checked`. Postgres is what Bambuddy recommends for multi-printer installs, so this was not a corner. All smart-plug timestamps are now naive UTC via a shared `utcnow_naive()` / `to_naive_utc()`, and the snapshot-delta query normalises its bounds the same way. **Tests.** 8 cases on the derivation (today and yesterday from the counter; yesterday absent until two midnights have passed; nothing derivable before the first; a counter reset reports nothing rather than a negative; another plug's snapshots are not borrowed; a device-reported figure is never overwritten by our arithmetic). 4 on the REST driver, using the reporter's own `Switch.GetStatus` payload (the lifetime counter lands in `total` and *not* in `today`; a plug reporting both keeps them apart; a total path alone is enough to read energy at all; both counters share one HTTP fetch). 4 more pin the Postgres-unsafe datetime — mutation-verified: reintroducing the aware timestamp fails the guard. Migration applied and re-applied against a real Postgres to confirm it is idempotent and defaults to NULL. **Existing REST users:** if your Energy JSON Path points at a cumulative counter (anything from a Shelly does), move it to the new **Energy JSON Path (lifetime)** field — the form and the wiki now say which field wants which counter. ### Added - **Russian (Русский) UI translation (#2608, contributor @pterodaktil02)** — Bambuddy's interface is now fully available in Russian, bringing the total to 11 languages. Pick it under Settings → General → Language. The translation covers the whole UI — printer controls and statuses, build plate and bed, filament, and AMS — with context-appropriate terminology throughout, and preserves every interpolation placeholder so counts, names, and progress values render correctly. - **"Slice as designed" — keep a MakerWorld author's own settings when you slice server-side (#2611, reporter @kpp39)** — When you slice a project 3MF through Bambuddy, the SliceModal makes you pick a printer / process / filament triplet, and the slicer applies those with `--load-settings` — which *overrides* whatever the designer baked into the file's `Metadata/project_settings.config`. So a MakerWorld model set up for 5 walls came out at the picked profile's 2, and the reporter's own re-posted files lost their tweaks too. That override is correct for the flow's main job — re-slicing someone else's design for *your* printer and AMS, especially across models (an H2D design onto an X1C) *needs* the bed size and filaments swapped — but it left no way to say "just slice it the way the author set it up." **What's new.** When the source 3MF carries embedded settings **and** the picked printer matches the design's target model, the modal offers a **Use the file's built-in settings** checkbox. Tick it and Bambuddy slices with no `--load-settings` override, so the designer's walls / infill / filament choices drive the result; all four preset controls (printer, process, filament, bed type) grey out to show they're bypassed — the printer included, since it's unused on this path and changing it would only pull you off the design's target and hide the toggle again. **The printer-match gate is deliberate.** Honouring embedded settings only makes sense when your printer *is* the design's printer — applying them across models would drop the object on the wrong bed, which is the whole reason the profile path exists — so the toggle simply isn't offered otherwise, and there's no cross-printer re-targeting on this path. Filament comes from the file too, not your AMS picks; the hint says so. **Under the hood** this reuses the existing embedded-settings slice path (previously only a crash fallback) as a first-class, user-selectable mode; the response already flagged `used_embedded_settings`. **Not** in scope: merging a picked filament *over* the designer's other settings — that needs per-key precedence and is a separate future enhancement. **Scope.** One backend request flag + one branch, one gated frontend checkbox. No DB migration, no new permission, no new setting. Two new i18n keys (`slice.useEmbedded`, `slice.useEmbeddedHint`) translated in all 11 locales. - **A paused AMS runout now names the physical slot the printer is actually waiting for (#2587, reporter @Jostxxl)** — When a spool runs out mid-print, Bambuddy showed the firmware's generic HMS text — "insert a new filament into the same AMS slot" — which is exactly wrong when AMS Filament Backup is on: the firmware won't re-accept the depleted slot and advances to the next compatible one, so "the same slot" sends the operator to the wrong place. On the reporter's farm this meant reinserting into Slot 2 (where it ran out) did nothing, and the print only resumed after moving the spool to Slot 3 — with no on-screen hint that Slot 3 was what the printer wanted. **Root cause.** The printer's AMS payload carries `tray_tar` (the slot the paused print now expects) and `tray_pre` (the slot that ran out) right next to `tray_now`, but Bambuddy parsed `tray_now` only and dropped the other two at ingest, so "which slot does the print expect" never reached the API or the UI. **What changed.** `tray_tar`/`tray_pre` are now captured on printer state and, while the print is **paused**, resolved to global tray IDs and surfaced on the status payload (both the REST poll and the live WebSocket push) as `expected_tray` / `previous_tray`. The AMS graphic highlights the expected slot with a pulsing amber ring (and a down-arrow badge) and marks the ran-out slot in red, and the HMS error is re-described to name them directly — e.g. "Filament ran out in AMS-A · Slot 2. The printer is now waiting for compatible filament in AMS-A · Slot 3. Insert a spool into AMS-A · Slot 3, then select Retry." **Honest when it can't tell.** On a single regular AMS the reported slot is already the global ID; on multi-AMS it's a local slot that's resolved against the print's snow-encoded mapping field, and AMS-HT IDs (128–135) pass through. When the slot can't be resolved unambiguously (multi-AMS with no usable mapping), the graphic highlights nothing and the message says so — "Bambuddy could not determine which slot the printer now expects — check the printer screen" — rather than pointing at a guess. User AMS friendly-names are honored in the labels. **Scope.** Guidance is populated only while paused, so a healthy print's normal target churn never highlights a slot or spams the log. Backend resolver, the ingest parse, and the modal re-description are covered by new unit/component tests; the runout copy is translated in all 11 locales. - **The sponsor surfaces now ask a print farm a different question than they ask a hobbyist** — Since the in-app sponsor banner and milestone toast shipped in v0.2.4.8, both have made exactly one ask, to everyone: chip in a few dollars to keep Bambuddy independent. That ask works — new sponsorships went from 0.40/day to 1.40/day in the fifteen days after the release, and clicks through to GitHub Sponsors rose 4.3x on a *falling* web traffic base. But it is the wrong ask for part of the audience. Someone running twelve printers as a business does not want to donate $5; they want a support contract, an invoice, and somebody accountable when the line stops. They were being shown a donation button and, unsurprisingly, ignoring it. **What changed.** At **5 or more configured printers** the Settings → General banner and the milestone toast both make the commercial ask instead — priority support, commercial licensing, invoicing — and link to the new **bambuddy.cool/business.html** rather than the sponsor tiers. Below that, nothing changes at all. It is the same single interruption either way: same milestones, same 14-day cooldown, same one-toast-per-session guard. Only the ask changes, so nobody sees more nagging than before. **Configured printers, not active ones.** The count deliberately ignores `is_active`, which is the maintenance-mode flag rather than a fleet-size signal. A farm with eight machines and five of them on the bench for nozzle swaps is still a farm — filtering on `is_active` would have counted three, downgraded them to the hobbyist pitch, and done it precisely when they were having the worst day. **The page concedes the licence up front.** business.html opens by stating plainly that Bambuddy is AGPL-3.0, that running it inside your own business costs nothing, and that no licence is required no matter how many printers you have — because that is true, and a page that implied otherwise would be a lie the audience would catch immediately. What it then offers is the set of things a licence cannot give you: priority support with a named contact and agreed response times, commercial licensing for the narrow case where you actually need it (redistribution, OEM, shipping Bambuddy on an appliance), fleet deployment and custom development, and operator training. No price list — those conversations are scoped individually. **Attribution is preserved.** Both surfaces keep their existing Matomo `?from=` tags (`app-settings`, `app-toast-{milestone}`), so the business funnel is measurable from day one on the same dashboards as the personal one, and the split between the two is visible without any new instrumentation. **No telemetry was added**, and none is needed: fleet size is read from the printers list the app already has cached. **Scope.** Frontend only — no backend change, no schema change, no migration, no new permission, no new setting. The audience split is one shared helper (`utils/fleetAudience.ts`) so the threshold lives in exactly one place. **Tests.** 7 new cases: the boundary in both directions (4 printers → personal, 5 → business); the maintenance-mode trap (8 printers with 5 inactive still reads as business); the cold-cache race (the toast waits for the fleet to load rather than defaulting to zero printers and pitching a farm as a hobbyist); both banner variants including the assertion that the commercial copy *replaces* the donation copy rather than sitting beside it; and the `?from=` tag surviving on both paths. All 7 mutation-verified — forcing the threshold out of reach, or dropping the fleet-load gate, fails them. **i18n.** 4 new keys (`sponsors.toastBusiness`, `businessCta`, `businessTitle`, `businessTagline`) translated in all 11 locales; parity 5616 keys. - **Cam Wall on its own URL, and on a TV that isn't logged in (#2531, reporter @cadtoolbox)** — The Cam Wall was reachable exactly one way: click the Cam wall button on the Printers page. It had no URL, so you couldn't bookmark it, link to it, or point a wall-mounted screen at it. It now lives at **`/camwall`**, and a button next to the Cards / Cam wall toggle opens it there. Signed in, that page is the same wall you already know — tiles clickable, settings popover working, the knobs shared with the Printers page through the same localStorage keys, so a change in one follows you to the other. **The TV case is the hard half.** A screen in a workshop has no login session, and a wall tile needs two things a camera token could not previously fetch: the list of printers, and each one's status for the state badge. Both sit behind `PRINTERS_READ`, so a kiosk got a 401 and an empty wall. The obvious fix — let the existing `camera_stream` token through to `GET /printers` — is the wrong one: that response carries every printer's `serial_number` and `ip_address` even in its non-secret shape, and a URL pinned to a lobby TV lives in the browser history, in the kiosk's config file, and on the screen itself. So the Cam Wall gets a **purpose-built read-only feed** at `GET /api/v1/camwall/printers` that serves only what a tile draws: id, name, camera rotation, connected, state, progress, layers, remaining time, HMS codes. No serial. No IP. No access code. **And no filename** — a token wall renders the compact overlay, so the field simply isn't served rather than being served and then hidden client-side; the part on the bed is never named to a room anyone can walk into. **A second scope, not a wider one.** The feed is gated on a new `camwall` token scope alongside `camera_stream`. A Cam Wall token reaches the video *and* the tile metadata; a camera-stream token reaches the video and is refused by the feed. That matters because `camera_stream` tokens are already in the wild, minted by people who agreed to hand out a picture — shipping this must not retroactively grant them the ability to enumerate a fleet by name. Pick the scope when you create the token in **Settings → API Keys → Camera API Tokens**; the create dialog then hands you the finished kiosk URL, fully assembled, so nobody has to build it from the docs. **What a token wall gives up.** No settings popover and no click-through: a TV has nobody standing at it, and click-through would open a page the token cannot authenticate. The controls are not merely hidden — they aren't rendered, so a kiosk carries no focusable control it cannot act on. The overlay is capped at `compact` even if the URL asks for `full`. The screen can still be tuned from the URL: `?maxLive=9&interval=10&status=compact`, all clamped to the same ranges the popover enforces. **Statuses are polled, not pushed** — the page renders outside the app layout and its WebSocket provider, and a kiosk token cannot mint a WS ticket anyway; a wall is watched, not operated, so a 5-second cadence costs nothing. **Revoking the token cuts the display off** on its next request. **Tests.** 11 backend cases: no token / garbage token / revoked token all rejected; a `camera_stream` token refused by the feed (the assertion the separate scope exists for); a `camwall` token accepted; the payload's key set pinned so a future field can't quietly add a serial, an IP or a filename; a Cam Wall token passes the camera-stream gate so its own tiles fill; a camera-stream token still passes its own gate (regression guard on #1108); and the scope allowlist pinned so adding a third scope has to be a deliberate act. 7 frontend cases covering the kiosk feed being called with the URL token, the token reaching the `` URLs, no settings popover, inert tiles, `?status=full` refused, the expired-token message, and — the negative — a tokenless visit never touching the kiosk endpoint. **Scope.** New endpoint, new token scope, new route. No DB migration, no new permission, no change to the in-page wall. - **Live print progress for Virtual Printers in Bambu Studio / OrcaSlicer (#1887, reporter @YozenPL)** — Connect the slicer to a server-mode VP with a target printer bound and the Device tab shows the printer's AMS, temperatures and camera, but the print itself reads as a name and nothing else: no stage, no percentage, no layer count, no time remaining. The data was never missing — the bridge has the target's real `push_status` cached, `mc_percent` and all — Bambuddy was deliberately overwriting it with zeros. **Why it was zeroed.** #1558, the exact inverse complaint: a queue-mode VP that passed the live values through was read by Bambu Studio as *busy*, and the Send button went away for as long as the printer printed, which defeats the entire purpose of queueing. **Why you cannot simply have both.** Both slicers gate the Device-tab progress panel and the Send button on one and the same predicate — `MachineObject::is_in_printing()`, true when `gcode_state` is RUNNING / PAUSE / SLICING / PREPARE. `StatusPanel::update_subtask()` draws the progress bar on it; `SelectMachineDialog::update_show_status()` disables Send on it. Report the printer's state honestly and you get progress at the cost of Send; zero it and you get Send at the cost of progress. There is no field-level trick, because it is one boolean. **The fix.** There is exactly one state in the gap: `FINISH`. StatusPanel renders the full progress panel for it (`is_in_printing() || print_status == "FINISH"`), SelectMachineDialog does not consider it busy. The VP already parks there after every upload — that is the #1280 / #1658 send-modal handshake — which is precisely why the reporter saw a file name and no numbers: the slicer was already drawing the widget, and we were feeding it zeros. So while the target printer is printing and the VP has no upload of its own in flight, the report now holds `gcode_state=FINISH` and passes the real `mc_print_stage`, `mc_percent`, `mc_remaining_time`, `stg`, `stg_cur`, `layer_num` and `total_layer_num` through underneath it, at the existing 1 Hz push. Send stays enabled in every mode and #1558 does not come back. **What it costs.** The slicer's Pause / Resume / Stop buttons stay greyed for a server-mode VP, since it now reports a finished job rather than a running one — they were greyed before this change too, so nothing is lost; drive the print from Bambuddy, or use Proxy Mode, where the slicer talks to the printer directly and they work. `print_error` is never mirrored either: a fault on the printer would raise a modal error dialog in the slicer for a machine that did not throw it, and the printer's own card already reports it. **The upload handshake wins.** Mirroring is suppressed while a job is being handed over (`gcode_state=PREPARE`) and for five seconds after the last upload transition — the slicer only releases its in-flight-job lock when it sees FINISH carrying the `subtask_name` it just uploaded, so swapping in the printer's filename mid-handshake would wedge the send modal at "Downloading". Once settled, the report switches to the job that is actually on the bed, which is the one the user wants to watch. **Tests.** 8 cases: progress mirrors while the target prints; the mirrored state is never one the slicer reads as busy (parametrised over RUNNING and PAUSE — this is the assertion that keeps #1558 fixed); progress stays zeroed while the target is idle, while an upload is in flight, and inside the settle window, where the slicer's own filename is still echoed back at it; mirroring resumes once the handshake has settled; `print_error` is suppressed while the rest still mirrors. Verified by mutation — forcing the mirror off fails five of the eight. **Scope.** Backend only, non-proxy VPs with a target printer bound. No DB migration, no schema change, no new setting, no new permission, no i18n change. - **Slicer Pipelines — multi-copy batches, class targeting, fanout strategies, runs dashboard, retry-failed, live WS updates (#1425 PR C — completes the v3 design)** — The PR A/B drop turned slice-modal preset bundles into one-click dispatches with a pinned target printer. PR C closes the original issue with full production-batch semantics: an operator picks a saved pipeline, types in a number of copies, and Bambuddy slices once and distributes the prints across a fleet according to the pipeline's chosen fanout strategy. The runs dashboard surfaces every active and historical run with filters, per-row expandable per-copy status, cancel-in-flight, and retry-failed-copies. WebSocket pushes keep the dashboard and the in-Settings "Last run" chip live without polling. **Backend.** `PipelineRunCreateRequest.copies` (Pydantic `ge=1, le=1000`) replaces the implicit 1 from PR B; the orchestration loop creates one `PipelineJob` row per copy. `SlicerPipelineUpdate` accepts `target_kind` (`specific_printer` / `printer_class`), `target_model_class` (Bambu model code: A1 / A1 Mini / P1P / P1S / P2S / X1 / X1C / X1E / H2D / H2D Pro / H2C / X2D), and `fanout_strategy` (`max_parallel` / `round_robin` / `fill_one_first`). A new `pipeline_max_copies` setting (default 50, Pydantic `ge=1, le=1000`) gates the copies input in the Run-with-pipeline modal and is enforced again at `POST /run` time so an API caller can't bypass the cap. PR C also adds `PipelineRun.parent_run_id` (nullable FK to itself, ON DELETE SET NULL) so retry runs link back to the run whose failed copies they re-attempt. **Eligibility for class targeting.** The matcher in `services/pipeline_eligibility.py` now branches on `pipeline.target_kind`: the specific-printer path is unchanged (PR B parity), the new class-targeting path enumerates every `Printer` whose `model` matches `pipeline.target_model_class`, runs the per-printer slot-by-slot check for each via a `status_lookup` closure that the route handler hands in (so the matcher stays pure-ish for unit tests), and returns a top-level `printer_reports: list[PerPrinterReport]` with `ok` derived as `any` across the candidates. New issue kinds: `no_class_matches` (the install has zero printers in the chosen model class) and `class_not_set` (target_kind is `printer_class` but no model was picked). The lenient-policy story is the same — operators can `Run anyway` past blocking issues, and `PipelineRun.eligibility_overridden` is set so the audit trail shows it. **Orchestration + fanout.** A new `_pick_assignments(pipeline, copies)` helper returns `[(printer_id_or_None, target_model_or_None), …]` of length copies per the picked strategy. `max_parallel` sets `target_model=pipeline.target_model_class` on every queue item and leaves `printer_id=None` — the existing print scheduler's model-based dispatch picks any idle matching printer per item; the result is that multiple printers grab work in parallel without any new scheduler code. `round_robin` enumerates eligible printers (`is_active=True`, model matches) ordered by id and assigns copy `i` to `eligible[i % len(eligible)]` — each item gets a fixed `printer_id`, the wear distributes evenly. `fill_one_first` pins every copy to `eligible[0]` so a one-printer fleet stays one-printer even when others come online mid-run; the documented trade-off is that a printer failure freezes the queue at that printer until the operator intervenes. All three flows reuse the same slice-once path; the slice runs through `slice_dispatch.enqueue` exactly as PR B did so the persistent progress toast renders end-to-end for batches just like single-copy runs. **Routes.** `GET /pipeline-runs?limit&offset&pipeline_id&status` is the dashboard endpoint — newest-first, paginated, filterable by pipeline and persisted snapshot status. `POST /pipeline-runs/{id}/retry-failed` counts the parent's failed-or-cancelled jobs at the live (queue-entry-aware) status level, builds a fresh `PipelineRunCreateRequest` with `copies=that count` and `force=True` (operator already accepted eligibility on the parent), routes it through the existing `run_pipeline` handler, and stamps `parent_run_id` on the result. Returns 400 when the parent's source or pipeline was deleted, or when there are no failed copies to retry. `POST /pipeline-runs/{id}/cancel` extends PR B's cancel to cascade across N queue entries — only the ones still in `pending` / `queued` are touched so in-flight prints continue on the printer (operator must Stop on the machine). **WebSocket.** New `pipeline_run_updated` event type carries the full materialised `PipelineRunResponse` and fires on every state transition (`queued → slicing → dispatching → in_progress → completed | failed | partial_failure | cancelled`). Per-user routing via `ws_manager.broadcast_to_user(run.created_by, …)` so each operator sees their own runs without cross-user noise; auth-disabled installs broadcast to all connections (PR B's pattern). The frontend's `useWebSocket` switch handles it by invalidating both `['pipeline-runs-all']` (the dashboard) and `['pipeline-runs', pipeline_id]` (the per-pipeline "Last run" chip in Settings). The dashboard still polls every 15 s as a belt-and-suspenders for missed messages. **Run status roll-up.** A new `_roll_up_run_status` function computes the run-level status from the per-job statuses at read time: all-completed → `completed`, any in-flight → `in_progress`, some completed + some failed → the new `partial_failure` status (this is what gets the Retry-failed button), all failed → `failed`. The persisted snapshot is still written on terminal transitions for the dashboard's status filter to remain useful. `copies_completed` / `_failed` / `_cancelled` / `_in_progress` counts ride on the response so per-row "1/3 · 2 failed" summaries don't need a second query. **Frontend.** The Settings → Workflow → Pipelines pipeline editor grows three new controls in the edit form: a radio for `target_kind` (Specific printer / Printer class), a model-class picker filtered to the models present on at least one installed `Printer` row (so users can't pick "H2C" if they only have X1Cs), and a fanout-strategy radio with the three options labelled with their use cases. The read-only row reflects class targeting with a "X1C · Round robin" line in place of the printer name. `RunWithPipelineModal` grows a number input for copies bounded by `settings.pipeline_max_copies`, accepts class-targeted pipelines (the "Apply pipeline" button is enabled when the pipeline has either a pinned printer OR a class target), and the pipeline-list row shows "Any X1C" instead of a printer name for class pipelines. The "Run pipeline" Setting → Workflow → Queue & Dispatch sub-tab gets a new "Slicer Pipeline limits" card with the max-copies input (bounded 1–1000 client-side, server enforces the same). **New dashboard page at `/pipelines/runs`** (sidebar entry under Print Queue, gated on `pipelines:read`). Lists every run across every pipeline with two dropdown filters (pipeline + persisted snapshot status) and pagination at 25 per page. Each row shows pipeline name, status chip (`partial_failure` is amber), source file, created-at timestamp, and "{completed}/{copies}" + "{failed} failed" rollup. Click the chevron to expand a per-copy panel listing each `PipelineJob`'s assigned printer + status + error message. In-flight runs get a Cancel button; partial-failure / failed runs get a Retry-failed button. **i18n.** ~43 new keys across `nav.pipelineRuns`, `pipelineRuns.*` (title / filters / pagination / job-status chips / toasts), `settings.pipelines.field.*` (targetKind / fanout / class), `settings.pipelines.runs.status.partial_failure`, `settings.pipelineLimits.*`, `library.runWithPipeline.*` (copies / copiesHint / classTarget / issue.noClassMatches / issue.classNotSet), and `common.previous` / `common.next` — translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5516 leaves per locale, no English fallback. `Copies` / `{{n}} copies` / `max {{n}}` added to the French + Italian cognate allowlists where they're genuine. **Tests.** Six new backend cases in `test_pipeline_runs_api.py` covering copies-cap rejection (schema gate at 1000), 3-copy run creates 3 jobs with sequential `copy_index`, class eligibility with two X1C candidates returns a 2-entry `printer_reports` array, class eligibility with no matching printers in install returns `no_class_matches`, dashboard list endpoint with pagination + status filter, retry-failed correctly counts failed jobs from a partial-failure parent and stamps `parent_run_id`. Plus the existing 16 PR A/B cases were lightly updated where `class_not_set` is now a valid no-target signal alongside `printer_not_set`. Five new frontend cases in `PipelineRunsPage.test.tsx` pin the dashboard's empty state, list rendering, Cancel button on in-flight runs, Retry-failed button on partial-failure runs, and per-row expand to show jobs. Three updated frontend cases (`RunWithPipelineModal.test.tsx`) assert the new four-arg signature on `runPipeline` (`pipelineId, source, force, copies`). One updated `SettingsPage.test.tsx` sidebar-order test reflects the new `pipelineRuns` nav entry between `queue` and `projects`. **Suites.** `pytest -n 30 backend/tests/` 6539/6539 green; `npx vitest run` 2284/2284 green (173 files); `npm run build` clean; `python -m ruff check backend/` clean; `node scripts/check-i18n-parity.mjs` clean. **Scope.** PR C closes the v3 design — no further pipeline PRs are queued. The existing print scheduler's model-based dispatch (`PrintQueueItem.target_model` + `target_location` + `required_filament_types`) is the only thing that makes class targeting actually distribute work; PR C just plugs into it. The `fill_one_first` strategy's "one printer fails, queue stalls" trade-off is documented in the editor's option-row hover-hint and in the orchestrator code comment — it's the correct behaviour for "I want one printer to finish a batch end-to-end" and the wrong behaviour for "I want resilience"; the right strategy for resilience is `max_parallel`. **Cross-printer-class pipelines** (e.g. one pipeline targeting "any X1C OR P1S") remain out of scope — make two pipelines, one per class. - **Slicer Pipelines — Archive entry point + progress toast for pipeline-driven slicing (#1425 PR B follow-up)** — Two real gaps from the PR B drop. (1) The Run-with-pipeline button only existed in the file manager — operators who keep their working files in archives had to copy them out to the library to use a pipeline. (2) Triggering a slice via a pipeline produced a silent multi-second-to-minute wait — the manual SliceModal flow has the sticky `Slicing X — Generating G-code 75%` persistent toast, the pipeline path went through `asyncio.create_task` directly and never registered with `SliceJobTracker`. **Fix.** (1) `POST /slicer-pipelines/{id}/check-eligibility` and `POST /slicer-pipelines/{id}/run` now accept `source_archive_id` as an alternative to `source_library_file_id` (XOR — Pydantic validator rejects both-set and neither-set), and the eligibility-check and orchestration paths branch via `_resolve_source` which reads `archive.source_3mf_path` with a fallback to `archive.file_path`. `PipelineRun.source_archive_id` is a new nullable FK column (Postgres + SQLite `ALTER TABLE` in `run_migrations` — idempotent via `_safe_execute`). `PipelineRunResponse` echoes the field. ArchiveCard's context menu picks up a `Run with pipeline` item alongside the existing Slice action (only on source archives — gcode archives already have Print + Open in BambuStudio), gated on `useSlicerApi` + `pipelines:run`. Path-safety: `Path(base_dir) / archive.source_3mf_path` carries a `SEC-PATH-OK` marker citing the upload-time validator at `_resolve_source_3mf_path` (same comment style as `routes/archives.py:3955`); the `LibraryFile.file_path` site gets the same treatment. (2) The pipeline orchestrator is now the `run` callable of a `slice_dispatch.enqueue` call — the same dispatcher the manual `SliceModal` flow uses — instead of a bare `asyncio.create_task`. The SliceJob's lifecycle (`pending → running → completed/failed`) drives the existing progress toast end to end: same persistent toast, same `Generating G-code 75%` weave from the sidecar's `--pipe` channel, same auto-replace with a transient success/error toast on terminal. `PipelineRun.slice_job_id` is set on the run row before the route returns 202, so the frontend can call `useSliceJobTracker().trackJob(slice_job_id, source.kind, source.filename)` from `RunWithPipelineModal`'s `runMutation.onSuccess` — same one-call surface that `SliceModal`'s slice mutation already uses. (3) `RunWithPipelineModal`'s `source` prop is now `{kind: 'libraryFile' | 'archive', id, filename}` (mirrors `SliceModal.SliceSource`); `api.checkPipelineEligibility` + `api.runPipeline` take a discriminated-union source argument and route to the right backend field. `PipelineRun` TS type grows `source_archive_id`. **Tests.** Three new backend cases in `test_pipeline_runs_api.py` — archive-source happy path (creates a PrintArchive row + on-disk file, posts with `source_archive_id`, verifies the response carries `source_archive_id` + `slice_job_id` from a stubbed `slice_dispatch.enqueue`), XOR rejection both-set, XOR rejection neither-set. The existing three run/cancel cases were updated to patch `backend.app.services.slice_dispatch.slice_dispatch.enqueue` (the new mock target) instead of the removed `_run_pipeline_orchestration` helper, and the run-happy-path now asserts `slice_job_id == 9001` arrives on the response. One new frontend case in `RunWithPipelineModal.test.tsx` pins the archive flow end to end (`checkPipelineEligibility` called with `{kind: 'archive', id: 7}`, then `runPipeline` with the same). The existing fast/slow path tests were updated to wrap in `SliceJobTrackerProvider` (the new `useSliceJobTracker` hook requires it) and to assert the new discriminated-union source argument. **Suites.** `pytest -n 30 backend/tests/` 6533/6533 green; `npx vitest run` 2279/2279 green (172 files); `npm run build` clean; `python -m ruff check backend/` clean; `node scripts/check-i18n-parity.mjs` clean. **Scope.** No new i18n keys — both fixes reuse the existing PR B keys. No new permission. The archive flow only branches at the source-resolution layer; everything downstream (eligibility, slice, queue dispatch) is the same code path the library flow uses. PR C scope (multi-copy + class targeting + fanout) is unchanged. - **Slicer Pipelines — Run a pipeline on a file with one click (#1425 PR B)** — PR A landed the bundle (save & apply preset slots in the SliceModal). PR B turns that bundle into an actual one-click dispatcher: file-manager rows now carry a `Run with pipeline ▾` button that slices the source through the pipeline's pinned printer/process/filament/bed-type combo and enqueues the print on the pipeline's pinned target printer. **Scope.** Single-target dispatch — `target_kind='specific_printer'` only. Multi-copy batch + class targeting + fanout strategies are PR C; the schema columns are already in place from PR A so PR C is code-only. **Backend.** Two new SQLAlchemy models — `PipelineRun` (one row per Run-pipeline click, carries the slice_job + sliced_library_file ids + snapshot status) and `PipelineJob` (one row per copy; PR B always 1, PR C variable). Soft-link to slicer_pipelines via `ondelete='SET NULL'` so run history survives a pipeline delete; same for source_library_file. **`status`** on the run is a *persisted snapshot* that gets terminal transitions written (slice failure, cancel, completion); in-flight reads roll up the live state of the linked queue entry via `_compute_run_status` — that keeps the status accurate (`pending → printing → completed`) without a background watcher writing on every queue tick. **Eligibility matcher** at `services/pipeline_eligibility.py` — given a pipeline + the live `PrinterState` from `printer_manager.get_status`, returns a structured report with typed issues: `printer_not_set`, `printer_not_found`, `printer_disabled` (from `Printer.is_active` shipped with #1476), `printer_offline`, `filament_type_mismatch`, `filament_color_mismatch`, `ams_slot_missing`, `filament_unverified` (cloud/standard tier presets can't be statically read here; surface as info, not a block). Canonical filament-type map mirrors `print_scheduler._canonical_filament_type` so `PLA Basic` / `PLA Matte` / etc. all collapse to `PLA` for the type comparison; colour normalises to six-hex-digit lowercase. **Eligibility is lenient with confirmation** — the report drives the frontend confirmation modal, but the user can `Run anyway` (sets `eligibility_overridden=True` on the run row so the audit trail shows which runs bypassed pre-flight). **Routes.** Two new routers — `pipeline_run_create_router` mounted under `/slicer-pipelines` (POST `/{id}/check-eligibility`, POST `/{id}/run`, GET `/{id}/runs?limit=N`) and `pipeline_run_router` at `/pipeline-runs` (GET `/{id}`, POST `/{id}/cancel`). `POST /run` returns 202 with the run shape; orchestration happens in a fire-and-forget `asyncio.create_task` that opens its own DB session (the request's session is closed by the time it runs) and walks: status='slicing' → `slice_and_persist` with the pipeline's `SliceRequest` → on success `status='dispatching'` + insert `PrintQueueItem` with `printer_id=target_printer_id, library_file_id=sliced_library_file_id`. The existing scheduler picks the queue entry up on its next tick. `POST /run` with eligibility issues and no `force` returns 409 with the report inside `detail` so the frontend can render the same confirmation modal it would for an explicit pre-flight; `force=true` bypasses the 409 but a missing `target_printer_id` still 400s (defence in depth — the UI can't enqueue the print without a target). `POST /cancel` is idempotent on terminal states and cascades to the linked queue entry when its status is still `pending` / `queued` (in-flight prints continue — operator must Stop on the printer itself). **SlicerPipeline.target_kind / target_printer_id** become writable via `PUT /slicer-pipelines/{id}` — the schema accepts both fields, the route treats `target_printer_id=0` as "clear" (the empty-`