Note
This is a daily beta build (2026-09-07). It contains the latest fixes and improvements but may have undiscovered issues.
Docker users: Update by pulling the new image:
docker pull ghcr.io/maziggy/bambuddy:daily
or
docker pull maziggy/bambuddy:daily
**Tip:** Use [Watchtower](https://watchtower.nickfedor.com) (image `nickfedor/watchtower`) to automatically update when new daily builds are pushed.
Added
- Sub-projects on the Projects page can be folded away (#2991) — A project with sub-projects drew every one of them expanded underneath it, at every level, with nothing to shut. That is fine for two projects and unusable for a three-level hierarchy over a couple of hundred archives, where the page becomes one long scroll before the first thing you were looking for. Each group's "Sub-projects of X" caption is now a chevron that folds that group, and a Collapse pill next to the status filter tabs sets the default for the whole page and is remembered across reloads. The caption stays visible when a group is shut — it is the way back in, and it carries a count of what is behind it. That count is of the cards actually nested there, not the card's own sub-project badge: the API counts sub-projects across every status on purpose, so under the default Active filter the badge can legitimately say 2 where only one card will unfold. A group folded by hand deviates from the default until the pill is pressed, which resets those deviations rather than leaving a group defying the switch that was just flipped. Nothing changes for a page with no nesting, where the pill is not shown at all, and the default with no stored preference is still fully expanded.
- A spool can carry a different filament preset on each printer model, and its K profiles are picked per hotend — A slicer preset is bound to a printer model:
Bambu PLA Basic BBL X1Cis not the same preset asBBL H2C. A spool stored exactly one, which was right until the same spool was used on a second model — the AMS slot on the other machine was then configured with a preset that machine has no profile for. The spool form's PA Profile tab is now a Printers tab holding both halves of the answer: a model list on the left, and on the right that model's filament presets and the K profiles for each of its hotends. Presets are keyed on the printer model, becauseBBL X1Cis the same preset on every X1C you own and asking once per machine would mean picking the identical value twice; K profiles stay keyed on the individual printer, extruder and nozzle diameter, because a K value is measured on one physical hotend and two machines of the same model legitimately differ. Both halves cover every nozzle size — 0.2, 0.4, 0.6 and 0.8 — not only the size currently fitted, because a spool is configured once and nozzles get swapped: presets get a row per size (the preset is written to an AMS slot, a slot feeds exactly one nozzle, and Bambu names its presets per size anyway), and K profiles are laid out as a grid with size down the side and hotend across the top, a dash marking a size the printer has no calibration for. Anything left alone inherits the spool's own preset and keeps inheriting it when that changes later, so only what actually differs needs an override, and a spool nobody has configured behaves exactly as it did before. Each model is offered only the presets that name it — using the same matcher the Configure AMS Slot modal filters with, now shared between them — while presets whose name identifies no model, which is most user-authored and OrcaSlicer ones, stay available everywhere, and a preset already saved is never hidden from the control that shows it. Every preset carries an origin badge — Bambu Cloud, Orca Cloud, Local or Built-in — in the same wording and colours the Configure AMS Slot modal has used since #1623, because the same filament exists in several of those sources and which one is picked decides what actually reaches the printer. Auto-match fills every size of every model with the variant of the spool's preset that names it, preferring the variant for that exact size; a model with no such variant is left inherited rather than given an approximate one. The model list stays one row per model however large the fleet is, and carries a count of hotends still without a K profile so an unfinished spool is visible without opening anything. One limit worth knowing: a per-model override can be one of your own cloud presets, whose id the slicer refuses in a slot's filament field, so such an override configures the slot but is not used as the calibration link — the spool's own preset is used there instead. - K profiles distinguish High Flow from Standard nozzles — A printer files each calibration under a nozzle id of the form
HH00-0.4(high flow) orHS00-0.4(standard) and can hold both for one diameter — a maintainer's H2D carries 102 high-flow entries and 6 standard — because the same filament reads a different K through each. The picker labels every profile with the flow it was measured on, saving records it, and a stored profile is no longer applied when the fitted nozzle disagrees; the picker marks such a profile rather than letting it look configured while quietly doing nothing. Two spellings have to agree for that: a calibration entry saysHH00-0.4while the fitted nozzle reportsHH01, so the comparison is two characters, not four. An unknown flow on either side matches anything, which is what it must do — every K profile stored before this has none, and an X1C declares none on any profile at all (measured: all eight come back with an empty nozzle id) even though the machine really does take either nozzle, so treating silence as "Standard" and filtering on it would have dropped every X1C profile the moment a high-flow nozzle was fitted. - Every path that configures an AMS slot respects a spool's per-model preset and per-hotend K profile — The manual assign in either inventory mode, the RFID auto-assign, the Spoolman tag link, the re-fire when a slot goes from empty to loaded, the re-apply after a calibration-table refresh, and the re-selection when a Filament Track Switch moves an AMS to the other nozzle. The Configure AMS Slot dialog also opens on the spool's own configured values, falling back to the slot's last manual configuration and then the tray's RFID data, rather than ignoring what the spool was configured with on the one screen that looks like it exists for it. A printer card in expanded view now lists every fitted nozzle size rather than the first entry alone, which on a machine with two different sizes named one hotend and implied it was the whole printer.
- An Avery sheet can start at the first unused position, so a part-used sheet is not thrown away (#2879, requested and contributed by whitigol in #2918) — Label PDFs always began in the top-left slot, so the second batch printed onto a sheet that already had seven labels taken off it would have printed over the gaps. Spool labels are printed a few at a time as filament arrives, which meant spending a 30-slot Avery 5160 sheet on two labels or nothing. A Starting label position field in the print-label dialog now says which slot to begin at, counted the way the sheet reads — left to right, top to bottom, starting at 1. The offset applies to the first page only and later pages restart at slot 1, because the number describes the sheet already in the tray rather than anything about the job: the second sheet the printer pulls is a fresh one. Position 1 is the default and is what a request that omits the field means, so both label endpoints — built-in inventory and Spoolman — behave exactly as they did for anyone who never touches it. The bounds are per template and enforced on both sides, 1–21 for Avery L7160 and 1–30 for Avery 5160; the server derives them from the sheet layout table it already lays labels out from rather than carrying a second copy of the numbers, so the two cannot drift, and a non-default position is refused outright for the single-label roll templates where a sheet slot means nothing. In the dialog the two sheet buttons disagree about the same number — 25 is valid on a 5160 and off the end of an L7160 — so the button whose capacity the value exceeds is disabled and its hint is replaced by that sheet's own range, instead of leaving a disabled button next to guidance that says the value is fine. The sentence naming the skipped slots deliberately avoids i18next's reserved
countvariable: with no_oneform defined, a value of 1 resolves past the translation to the English default, so every non-English locale would have printed an English sentence at position 2 and only at position 2. Translated in all 13 locales, README and wiki updated, and covered by renderer, endpoint and dialog tests including the page-boundary cases where the selection exactly fills the offset first sheet and where it runs one past it. - Home Assistant sensors can be bound to a storage location, so a drybox reports its own humidity (#2824, requested and contributed by MagicMelody84 in #2827) — A temperature, humidity or battery entity can be attached to any storage location, and its reading shows where the filament in that location is already being looked at: on the spool cards in Inventory, and in three optional Inventory table columns that are off by default. Values can be colourised against the location's own thresholds, so a rack of dryboxes reads at a glance rather than one number at a time. Each binding carries its own alert rule — above/below for a numeric sensor, on/off for a binary one — and can raise a notification when it crosses into that state, on the edge only: a drybox that was already too humid when Bambuddy restarted has not just become too humid, and re-announcing every pre-existing alert on every restart is how people learn to ignore the alert. That notification is its own event with its own provider toggle and template rather than sharing the printer sensors' one, because a provider can be scoped to a single printer and a location has no printer to scope by — sharing the column would have delivered drybox alerts to a channel deliberately narrowed to one machine, with no way to have one without the other. A background poller reads the bound entities on a configurable cadence, defaulting to two minutes and floored at one, and polls before it sleeps so a restart does not leave every card blank for a full interval. Nothing is polled for a location that has no sensor bound, and in table view nothing is polled at all while the three columns are hidden, so the installs that never touch Home Assistant — which is most of them — issue no extra requests. New bindings are seeded from defaults that live on the server, not in the browser, since they become the alert rule written onto each row and two admins binding sensors from different machines must not seed different rules; the colour choices stay per-browser, because those are only how one person likes to read the page. Home Assistant's
moistureclass is deliberately not offered: it is a binary wet/dry leak detector rather than a humidity percentage, and treating it as one put "wet" in a percent-formatted column and let it block the real hygrometer on the same location. One entity can be bound to a location once, enforced by a unique index rather than by the check alone, because that check is read-then-insert and two concurrent creates could both pass it; the upgrade collapses any duplicate that slipped in before the index existed instead of failing to build over it. An API key can read the bindings but not create or edit them, matching the printer bindings exactly. Translated in all 13 locales, with backend and frontend regression tests throughout. - A fault's description is in the status response, so a client no longer needs its own copy of the table (#2926, proposed and analysed by sadontsev) —
HMS_ERROR_DESCRIPTIONShas been in the backend all along and the status response never carried it, so every consumer that wanted to tell a user why a print halted resolved the same 853 codes from its own duplicate of the same sentences — this repo's Python table, the frontend modal's, and at least one third-party iOS client whose catalogue exists purely because the server would not say. Each aged separately, and a push relay watching a printer could only manage "your printer needs attention" while the server already knew it was "Filament ran out. Please load new filament."hms_errors[]entries now carrydescription, defaulting to null so a client that has never seen the field is unaffected. It is resolved once, where the fault is parsed, rather than at the boundary that happened to prompt the request: there are three separate serializers of a fault — the status response, the WebSocket broadcast, and the print-completion payload the queue's failure reason is built from — and adding it to only the first would have delivered half the feature to a relay watching the stream, which is the likelier consumer. The queue's failure reason now quotes the same sentence instead of resolving the code a fourth time, and the notification path reads it rather than re-deriving its own. Resolving in one place is also what makes the three unable to drift, which is pinned by a test that asserts they agree. Resolution is exactly what the codebase already did, verified rather than assumed: an 8-charprint_erroris the catalogue'sMMMM_EEEEkey split in half, and a 16-charhms[]identifier is tried whole and then collapsed to its first and last groups, which is how the notification path, the queue's failure-reason helper and the frontend modal have always resolved those. The collapse is lossy — #2728 counts 65 documented faults falling onto0300_0001alone — and it is kept rather than tightened here because refusing it would not read the same data more strictly, it would stop describing faults that are described today and leave this field null while the UI shows text for the same fault. Narrowing it belongs with #2728, where both key spaces can move together. A fault the catalogue does not cover reports null and is still reported in full;full_codeidentifies it either way. The equivalence is pinned by a test that checks every catalogue code in both fault shapes across all three alert levels, so a future change to the lookup cannot silently stop notifications from firing. The text is English only and unlocalized, which the schema says next to the field. The frontend keeps resolving its own text for now; switching it over would change whatfilterKnownHMSErrorscounts across eight call sites, which is #1840 and #2728's argument rather than this one's. - A virtual printer can be told which address to advertise, so uploads work on Docker bridge networking (#2930, reported and diagnosed by sebimarkgraf) —
VIRTUAL_PRINTER_ADVERTISE_ADDRESSsets the address written into the MQTT status that BambuStudio and OrcaSlicer read their FTP upload destination from. It exists for deployments where that address is not one of the container's own interfaces: on bridge networking the virtual printer is reached on the host's LAN IP but binds a private one like172.24.0.2, and that private address is what the slicer was handed — so it opened an FTP connection to an address that does not exist on its network, which is the upload stalling around 10% with "Failed to send" that the troubleshooting page has been describing as a limitation with no fix. Set it to the address slicers use, alongside theVIRTUAL_PRINTER_PASV_ADDRESSthat already existed for the passive-data channel. The log line that arms the rewrite names its source, so(VIRTUAL_PRINTER_ADVERTISE_ADDRESS)versus(bind_address)says whether the variable reached the container. A value that is not a dotted-quad IPv4 is refused with one warning naming it and the address that would have been used before is used instead — deliberately, because refusing to rewrite at all would put the real printer's IP back in front of the slicer, which is the leak this path exists to close and strictly worse than the wrong local address.0.0.0.0counts as unset, and surrounding whitespace is tolerated for the sake of values pasted into a compose file. This is an environment variable rather than a change to how the advertised address is resolved, and that was the decision worth making carefully: the virtual printer already has a "Network Interface Override" field, but it feeds SSDP and the certificate's SAN list only, and reading it here would have moved the upload destination on every install that has one set — the multi-NIC, VLAN and Tailscale setups, which are the ones most likely to have been arrived at by hand and the least likely to survive being second-guessed. Unset, nothing about the resolution changes, which is pinned by a test. Host and macvlan networking still need none of this and remain what Virtual Printer is developed against; the variable removes one blocker rather than making bridge mode equivalent, and the wiki now says so in the three places that previously stated the host address could not be discovered at all. - Printer file downloads can be selected in ranges and print-history videos can be downloaded (#2850, requested and contributed by logikal in #2853) — The printer file browser's multi-select download now prepares large selections on the app data volume instead of buffering them in server and browser memory, uses per-file compression (videos stored, G-code/3MF compressed), reports partial results and preparation progress, supports cancellation, rejects over-large or under-space selections, and preserves the legacy API contract. Shift-click selects a contiguous visible range and hidden selections are discarded when navigating or filtering. Print History now offers attached timelapses, matching printer timelapses, and
/ipcamchunks when available; offline or unreadable storage is distinguished from an empty directory. Download tokens are single-use and resource-bound, API-key printer allowlists are enforced, FTP short reads are rejected, and abandoned staging is pruned. Translated in all locales; wiki updated. Covered by backend and frontend regression tests. - Bambuddy now asks a printer that refuses FTPS what it actually said (#2780, measured by grolmus) — When a printer's file service answers port 990 with something that is not TLS, Python reports
[SSL: WRONG_VERSION_NUMBER]and the bytes that caused it are gone, consumed by the TLS layer before the error surfaces. That has left #2780 open on a theory rather than a finding. The client now opens one plain connection straight afterwards and reads what the printer says, so the log carries the printer's own words — an FTP refusal such as421 Too many connectionswould identify the fault outright — and the line is marked as the one to quote in a report. Reading nothing is informative too, and says so: a healthy implicit-FTPS service stays silent until it gets a handshake, so silence means the refusal had already passed. It asks once per cool-off window rather than once per attempt, which keeps it to one extra connection per printer per five minutes — the suspected fault is a printer running out of connections, so the diagnosis must not add to it. What made this worth doing is a measurement from a nine-printer farm, reproduced here: a cleartext banner on the TLS port produces exactly the error the field reports, a genuine TLS version mismatch produces a different one, and a client with no version cap reaches a TLS-1.2-only peer unaided. So this failure was never a TLS-version problem, and the per-modelcap_tls_v1_2knob cannot affect it. Two of the three entries carrying that knob were added on the belief that it could; they are kept, since their reporters saw the symptom clear and nobody here has the hardware to re-test on, but they are now marked for re-test and the reasoning recorded next to them is what was measured rather than what was assumed. Both measurements are pinned by tests, so the explanation stays falsifiable. - The K value is on the AMS slot itself, not only in the popover (#2532, requested and contributed by gyrene2083) — Reading back a slot's pressure-advance value meant hovering it: the K factor lived in the filament popover alone, so checking whether a calibration had actually taken across four slots was four hovers, and comparing two of them side by side was not possible at all. Every slot card now carries the value under the material name, the way Bambu Studio shows it per slot — on regular AMS units, on AMS-HT, and on the external spool of a dual-nozzle machine. Only a value the printer actually reported is shown: a loaded but never-calibrated slot stays blank rather than inheriting the 0.020 that fills the popover's own field, and a slot the firmware reports as exactly 0 counts as uncalibrated the same way the stored K-profiles do. The label is shortened to K with the full localized name on hover, because "K Factor", "K-Faktor" and "Facteur K" ate the value itself — the whole point of the line — on cards under about 350px, and the figure is set in tabular numerals so it measures the same in Safari as in Chromium. Where one slot of a unit is calibrated and its neighbours are not, the neighbours hold the same row open so the fill bars stay level across the card.
- Dutch (nl) is now a supported interface language (#2891, requested and contributed by Igiegel) — Adds
nlas the fourteenth locale, listed as "Nederlands" in the language picker. The translation was contributed as a file on the issue and needed three corrections before it could be wired up, all of which the parity gate found. First, the ninestats.timeframe.*entries had their keys translated along with their values ('today'had become'vandaag'), which would have left the Statistics timeframe selector resolving nothing and rendering raw key names for every Dutch user — the values were kept and the keys restored. Second, the file was translated against an olderen.tsand was 84 leaves short, missing the Filament Track Switch feed prompts, the AI-detection status strings, the no-3MF internal-history banner, the batch-order stranded-plate notices, the Avery starting-position field and the wholelocationHaSensorssection from #2824; those were translated and added. Rather than splice them in,nl.tswas regenerated from theen.tsskeleton with the contributor's strings carried over, so its structure, key order and section comments now match the reference locale exactly and a future diff againsten.tsreads as content rather than as reordering. Third, 229 leaves were identical to English; each was checked individually and all were kept, because Dutch takes most technical UI vocabulary verbatim — printer, filament, status, nozzle, timelapse, dashboard — and Dutch slicer users use the English feature names (support, ironing, prime tower, gap fill) untranslated. Those 123 distinct values are now listed explicitly in aNL_COGNATESallow-list incheck-i18n-parity.mjs, the same shape the other twelve locales use, so the exemption is an enumerated translator decision rather than a blanket skip. Parity green at 6264 leaves across all 14 locales.
Changed
- Updating now repairs a service file that was written before the
--loop asynciopin existed (#3001) —install.shhas pinned the loop since 2026-07-05 (#1896), but nothing has ever rewritten an existing service file:install/update.shdoes agit reset --hard, a pip install, a frontend build and a restart, and never touches the unit. uvloop has been in every native venv sinceuvicorn[standard]enteredrequirements.txton 2025-11-28, and uvicorn's--loop autoprefers it, so every native install created in that seven-month window has been running on uvloop ever since and no amount of updating has changed that. It cost them every RTSP camera on 1.2.5.4, and before that it left them exposed to a Virtual Printer FTP upload being silently truncated into a corrupt.gcode.3mf— which is why #1896 shipped a ZIP-validation backstop alongside the pin, since the pin could never reach the installs that already existed. Both update scripts now add the missing flag themselves:update.shto the systemd unit,update_macos.shto the launchd plist, in each case while the service is stopped so the repair takes effect on the same restart. Only the one flag is ever inserted — a hand-edited port, extra hardening,ExecStartPrelines and everything else stay byte-identical, and the file is copied to a timestamped backup first. Anything that is not a single-line unit invoking uvicorn directly is described rather than edited: a wrapper script, anExecStartcontinued across lines, severalExecStartlines, a unit that is not writable, or a service carrying systemd drop-ins, since a drop-in may be what definesExecStartand editing the fragment would then change nothing while reporting success. A loop pinned deliberately is also left alone — someone who wrote--loop uvloopon purpose gets no argument, only the startup warning. The check reads the effectiveExecStartfrom systemd rather than the file, so a drop-in that already pins the loop counts and the repair is idempotent. - Bambuddy now says so at startup when it is running on uvloop (#3001) — every unit file this project ships pins
--loop asyncio, added for #1896 because uvloop's SSL layer can drop buffered data and truncate a Virtual Printer FTP upload into a corrupt.gcode.3mfthat is acked226and forwarded to a printer. Two populations run a unit nobody here wrote and therefore have no such pin: the Proxmox VE Helper-Scripts LXC, which composes its ownExecStart, and native installs created before that fix landed on 2026-07-05, which never gained the flag becauseinstall/update.shdoes not rewrite unit files. Neither had any way to know. #3001 only surfaced them because losing every camera at once is loud; a truncated upload is silent, and there is nothing to notice until a print fails from a file that was corrupt on arrival. Startup now logs one WARNING naming the loop, the risk and the exact flag to add. It is a warning and not a refusal: a server that answers requests beats a purist one that will not boot, and by the time any application code runs uvicorn has already chosen its loop. The check asks the running loop what it is rather than whether uvloop imports — uvloop is a hard dependency here, sincerequirements.txtpinsuvicorn[standard], so its presence says nothing about what is in use — and it matches on the module name so that asking the question never imports uvloop on a host that lacks it. - The spool form is wider, and its colour, weight and cost fields have their own tab — The Printers tab is a model list beside a detail pane, which needs the room. Colour, spool weights, price, category and storage location move out of the bottom of a long scroll into a Color & Cost tab, laid out in two columns rather than one short field per row. Filament identity and the slicer preset stay on the first tab.
- Home Assistant sensors moved out of the Smart Plugs settings tab into their own (#2824) — Sensors are things you read and plugs are things you switch, and with storage-location bindings joining the printer ones the two no longer belong on one page. Both now live under Settings → Sensors; nothing about the printer bindings themselves changed, only where they are. The template that carried the printer alert is renamed from "Home Assistant Sensor Alert" to "Printer Sensor Alert", along with the toggle and badge labels that name it, because once a storage-location alert existed beside it the old name no longer said which one it was; the rename only touches templates still holding the old default name, so one that was edited keeps whatever it was called. A battery-class printer sensor now draws a battery icon instead of the generic gauge — the printer map never had an entry for it and the shared one does, which reads as the omission it was rather than a choice worth preserving.
- Storage locations sort the way they are named (#2824) — The locations list was ordered by
ORDER BY name, which puts "Drybox 10" between "Drybox 1" and "Drybox 2". It is now sorted on the numbers inside the name, so a rack numbered past nine reads in rack order everywhere the list appears. - The Watchtower we recommend for daily builds is the maintained fork (#2917, reported by CamelT0E) — The daily-build instructions in the README, on Docker Hub and in every daily prerelease pointed at containrrr.dev/watchtower. That project has been archived and read-only since December 2025 and its last release, v1.7.1, is from November 2023, so anyone following the recommendation was being handed a container with Docker socket access that had not received a fix in over two years. Development continues in Nicholas Fedor's fork, which ships as
nickfedor/watchtowerand released v1.21.0 this month. All four references now point at watchtower.nickfedor.com and name the image, including the release-notes template indocker-publish-daily-beta.shthat produced the screenshot in the report — the READMEs alone would have left every future daily prerelease repeating the dead link. Existing images keep working; only the recommendation changed. - The Windows installer build is split in two so a signing request can wait for a human (SignPath Foundation) — Release tags are Authenticode-signed through the SignPath Foundation OSS programme, and the production certificate does not sign on demand the way the self-signed test certificate does: every request has to be approved by hand in the SignPath UI, because the Foundation verifies what is being signed and which build it came from. The submitting action waits for that approval with a default timeout of 600 seconds, which is ample when the test policy approves automatically in seconds and far too short once the wait is a person noticing a tag went out. A tag pushed at night would have failed the run ten minutes later with the installer already compiled and thrown away. The compile now ends in its own job that uploads the unsigned artifact and stops; a second job downloads it, signs it, and does the release-facing work, with the wait raised to an hour. Because the artifact is uploaded before the wait begins and is addressed by id, a missed approval window is recovered by re-running the second job alone rather than rebuilding the installer — which is the reason to separate them rather than simply raise the timeout in place. The second job runs for unsigned builds too, so the daily prereleases that are deliberately left unsigned to preserve the signing quota keep going out through exactly one set of alias, artifact and release steps. The property that matters is unchanged and now recorded next to the steps that depend on it: none of the alias, upload or release-attach steps carry
always(), so GitHub skips all three when signing fails or times out, and an unsigned.execannot reach a release. Nothing about the signed output changes, and the restructure behaves identically under the test policy — the request simply completes immediately instead of waiting — so it can be proven green before the production certificate arrives. - Every FTP session Bambuddy opens now records how it closed (#3009, reported by grengojbo) — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a
finally, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable.
Fixed
- K values missing on a second AMS, and its slots unconfigurable (#3044, reported by Zib-Astian) — an X2D with two AMS 2 Pro, one per hotend, showed K on every slot of the first and nothing on any slot of the second. Configure Slot was worse than blank there: the picker offered no matching profile, the slot read as though it had none bound, and choosing one changed nothing visible. Both came from the same rule. A calibration index can mean two different profiles on a dual-nozzle printer — on the maintainer's H2C, index 16 is the left hotend's black PLA at K=0.018 and 15 is the right's at K=0.020 — so Bambuddy resolves it against the slot's own hotend and shows nothing rather than the wrong number. But a printer does not always file one profile per hotend. This one files one per filament, so the second AMS's slots point at the same entries as the first, all tagged with a single extruder, and requiring a match found nothing at all. The hotend now has to actually appear in the table the printer sent before it is used to narrow anything; where it does not, the index stands on its own, which is what BambuStudio does for the same card. Printers that do file per hotend are unaffected — the wrong-nozzle number still cannot be shown, and the other hotend's profiles stay under Other K profiles. Also fixed alongside it: on a dual-nozzle printer the K-profile dropdown named the hotend twice on every matching option and three times on every other one.
- The Timeline ignored Shortest Job First (#3043) — turning SJF on reordered the pending list and the scheduler itself, but the Timeline tab kept drawing the queue in its pre-SJF order, indefinitely. It chained each swimlane's bars by queue position alone and was never told the setting existed, so the one view whose entire job is to say when each print will run was the one view answering for an order the scheduler had no intention of using. Bars now chain in the scheduler's own order — jumped items first, then shortest print time, then position — and the starvation guard is visible there too, so a long print that has finally come up shows as such instead of staying buried. All three surfaces that claim to show queue order (the pending list, the "if started now" ETA, and the Timeline) now share one comparator rather than three hand-copied ones. Along the way the pending list stopped folding a model name down to its first letter when grouping lanes, which had been interleaving Any X1C with Any X2D (and Any P1S with Any P1P) into a single run of rows.
- Preheat & Heat Soak delayed PLA prints by minutes with nothing to preheat for (#3041) — with the feature on, a queued PLA print sat for five to seven minutes before the file was even uploaded, and turning the feature off started it immediately. The per-filament map does what it says and gives PLA a chamber target of 0, and the chamber phase duly skipped — but the stage then heated the bed to the print's own temperature, waited for it, and held the full soak anyway, because the soak had no idea it was holding for a chamber that was never asked for. That is pure delay: the print's G-code sets the bed the moment it starts, so preheating it here only moved the warm-up ahead of the upload instead of overlapping with it, and there was no chamber to condition. A chamber target of 0 derived from the filament map now skips the whole stage, so PLA, PETG, TPU and PVA prints dispatch straight away with the feature left on. The one thing the skip still does is put the airduct flap back to cooling, which is a single command and no wait — an H2D left in heating mode by the ABS job before it would otherwise cook the PLA that follows. Explicit instructions are untouched: a chamber target of 0 typed into a print's own override still heats the bed and runs the soak, which is what the queue documentation has always promised it does, as does forcing a print's Preheat override to On. Prints that genuinely want chamber heat are unaffected, including on the P1S/P1P/A1 tier where the bed and the soak timer are the whole mechanism.
- Server-side slicing rejected MakerWorld 3MFs over filament-index fields, and the plate preview was never sanitised at all (#3030, reported by kielsucks) — Bambu Studio writes
0intowall_filament,sparse_infill_filamentandsolid_infill_filamentto mean "use whichever filament the object is set to". Bambu Studio and OrcaSlicer 2.4 both accept that; OrcaSlicer 2.3 and earlier counted these from 1 and reject it outright withwall_filament: 0 not in range [1.000000,...], so a sidecar pinned to one of those images refused the file before it ever looked at the profiles Bambuddy supplied. This is the same shape as the-1inherit markers fixed in #1201, with a different marker value, so the sanitiser's allowlist is now a key-to-marker map rather than one global constant — a-1on a filament index is a real value and is left alone, as is a0on a raft field. The key is removed rather than rewritten, which is what makes it safe on every build: the slicer then falls back to its own default, which is the same0on the versions where0was legal and1on the older ones, which is what "the active filament" means there. Fixed alongside, and the more consequential half: the plate preview never ran the sanitiser, so a file that sliced fine could still fail the automatic preview and fall back to a guessed filament list — and it matters more there than in a real slice, because the preview deliberately runs on the file's own embedded settings and so has no--load-settingspass that could supply a replacement for a rejected field. Both paths now share one sanitiser. - The Slice action offered Bambu Studio files its URI handler cannot load (#3029) — "Open in Slicer" was offered for
.stl,.stepand.stpas well as.3mf, on the reasonable assumption that a slicer which opens an STL from its own File menu will open one from a link. Bambu Studio does not. Everybambustudio://andbambustudioopen://URL goes through one import path that refuses any filename which is not.3mf, and it refuses it before fetching anything — the user gets "Download failed, unknown file format", which names the format and so reads as a broken model rather than an unsupported handoff. OrcaSlicer has no such limit: only MakerWorld links go down its equivalent path, and a link to your own Bambuddy goes to its general downloader, which does not look at the extension at all. So the action is now offered per slicer. With Open in Slicer set to Bambu Studio the File Manager offers Slice on 3MF only; set it to OrcaSlicer and STL, STEP and STP come back. In the 3D preview, where both slicers are reachable from one split button, a file the configured slicer cannot take is offered to the other one instead, with the button naming it rather than quietly sending the file somewhere the setting did not ask for. None of this touches the in-app slicer: with Use Slicer API on, Slice goes to the sidecar and still takes STL and 3MF whichever desktop slicer is configured. - "Slice" handed the slicer a download link that only worked once (#3029) — the Slice action in the File Manager, and Open in Slicer on the Archives page, mint a short-lived token and put it in the URL, because a protocol handler cannot carry an
Authorizationheader. That token was spent by the first request to reach the endpoint, which made the handoff depend on the slicer fetching the URL exactly once — and nothing guarantees that. Bambu Studio's own downloader retries three times after a failed attempt, transfers get resumed, on-access scanners fetch. Whichever request arrived first won and every later one got a 403, so the slicer opened on an error about the file instead of on the file, and the message it showed described the 403 body it had been handed rather than anything wrong with the model — which made this very hard to read from the user's side. The three protocol-handler downloads — a library file, an archive's sliced 3MF, an archive's source 3MF — now accept their token for the rest of its five-minute life. Nothing else about it changed: it is still bound to the single file it was minted for, still expires in five minutes, and is still refused everywhere else. The two downloads that are not handoffs stay single-use, because what they hand over is itself consumed — a prepared printer-file bundle is deleted the moment it has been streamed. Fixed on the way through: Open source 3MF in slicer never worked at all with authentication enabled. The gateway that lets these token-in-URL downloads past the login check matches the path fragment/dl/, and the source 3MF's route sayssource-dl, which does not contain it — so the slicer's header-less request was turned away as unauthenticated before the route's own token check ever ran. It failed on every install with auth on, and only there. camera:viewwas required to see any image in the app (#3025, reported by lonix) — thirteen routes that have nothing to do with a camera took the camera stream token as their credential: library and archive thumbnails, plate previews and plate thumbnails, timelapses, print photos, archive QR codes, project cover images, print-log thumbnails, printer cover images and external-link icons. A browser cannot put anAuthorizationheader on an<img src>, so those routes need a credential that fits in the URL, and the camera token was the only one there was. But the only way to mint one iscamera:view, so a user granted library access to their own files got a grid of broken images until they were also granted the live camera — which on a home install points at the room the printer is in, and is not something you hand to a commission client so their own thumbnails will load. Those routes now take a new media token, minted byPOST /auth/media-tokenbehind plain authentication and carrying the identity of whoever asked, so each of them applies the permission its own resource is governed by:library:read_ownfor a library thumbnail,projects:readfor a project cover,external_links:readfor a sidebar icon. The camera stream token keeps the three routes it was named for. The frontend fetches a media token for every signed-in user and asks for a camera token only when the user can actually mint one, which also stops the 403 that used to fire on every page load for everyone else. Fixed on the way through: the printer file-browser's plate thumbnails, which had no query-token support at all and so 401'd in the file manager whenever auth was enabled; and the project cover image in the edit dialog, whose cache-busting?v=was appended after the token and corrupted it.
Upgrade note: a long-livedcamera_stream,camwalloroverlaytoken is no longer accepted on those thirteen routes — the split is the point of the change, and those tokens are handed to kiosks, walls and Home Assistant to display video. The cam wall, the streaming overlay and the kiosk views use only the three camera routes and are unaffected. If you have an integration pulling a thumbnail or a cover image with a pasted camera token, it should authenticate with an API key instead: the media routes acceptX-API-KeyandAuthorization: Bearerdirectly, which the camera-token-only versions did not.- The Finance sidebar entry was hidden from every non-admin, whatever their permissions (#3023, reported by lonix) —
cost_centers:read_ownexists so a user can see their own wallet, balance and cost-centre spend, and the page itself always worked if you typed the URL. The sidebar hid it anyway. It decides whether to offer Finance by readingbilling_enabledfromGET /settings, which requiressettings:read; a non-admin gets 403 there, so the value arrived undefined and the entry was suppressed for exactly the people the permission was written for. The shell read three more fields from that same 403, and one of them failed the other way: the Notifications gate tests forfalse, which an undefined value never is, so an administrator who switched user notifications off still left the entry showing to the non-admins it governs — a fault no administrator could reproduce, because administrators can read/settings. The sponsor prompt also showed EUR regardless of the configured currency, and the update check ran on installs where it had been turned off.settings:readcannot be the price of any of this: it also grants sight of the SMTP, LDAP and MQTT credentials. These four now come from a newGET /settings/ui-flags, which asks only that the caller be signed in. It is deliberately not more fields on the existing public/settings/ui-preferences: that endpoint is served to anyone at all because its contents are defaults shipped with the app, while these describe how your particular install is configured, so they sit behind authentication instead. - A queued print showed "ASAP" in the queue even when Queue was the option chosen (#3018, reported by kilrah; also #2557, reported by ddavidebor) — the Print dialog offers ASAP, Queue and Schedule, but ASAP and Queue differ only in where the item lands in the list, and neither is recorded on the item itself. The queue's time column had nothing to read but the scheduled time, so it labelled every unscheduled item "ASAP" — the name of the one mode the user may well not have picked. Someone who chose Queue, watched the row appear as ASAP and then watched it start, reasonably concluded Bambuddy had overridden them. That column answers when an item runs, so it now says that: When a printer is free, in all fourteen languages. The dispatch itself was correct and is unchanged — a print scheduled for later does not reserve the printer until then, so an unscheduled item behind it uses the printer in the meantime rather than leaving it idle until the scheduled time. The queue's own per-printer log line was no help in telling those apart either: it called a printer "not available" whether the queue could not use it or had just picked it for this pass, and printed printer state read at the moment of logging rather than the state the decision was made on — so a support bundle could show "printer 1 not available — state=IDLE" immediately above that printer receiving a job. Each printer now reports which of the two it was, and why.
- An archive left empty by a printer refusing FTPS blamed the slicer (#2780, reported by AntonPalmqvist) — when a printer's file service answers port 990 with something that is not TLS, Bambuddy pauses transfers to it and the print in flight is archived with its name and timing only. That cause has been recorded on the archive since #2957, but the Archives banner was never taught about it: it ranked the three causes it knew, found none of them, and fell back to the original wording — the slicer did not leave the file on the card, go and switch on "Store sent files on external storage", here is a link to installation step 4. Every part of that is wrong for a refused handshake. The slicer did write the file, the reporter could see it on the stick from his own computer, the setting was already on, and there is nothing on his side to change. Worse, the banner dismisses one-shot into browser storage, so being shown the wrong explanation once meant never being shown the right one. It now has its own wording, in all fourteen languages, saying that the printer refused the connection, that this is not a slicer setting and not something the operator did, that Bambuddy comes back for the file after the five-minute pause so a brief episode fills itself in, and that a card still empty means the refusal outlasted the retry. It links to the troubleshooting entry for the handshake failure rather than to the installation guide. This cause now outranks the other three rather than falling to the bottom: the other three describe an install working as configured and each ends in something the operator can change, while this one reports a fault we cannot yet explain — and given the one-shot dismissal, ranking it below anything else would have hidden it permanently. It cannot mask a permanent cause in return, because the retry clears the marker when it succeeds, so a card still carrying this one is a printer whose file service is still refusing. The wiki page these reports are pointed at has also been corrected: it told people to power-cycle the printer, which the reporter who prompted that advice did on both of his printers to no effect, and which the code stopped saying for that reason. It now states what has been measured, says plainly that the trigger is unknown, and names the one log line worth collecting.
- Custom filament profiles arrived in the slicer as Generic, or as the Bambu profile they were built on (#3003, reported by marivo) — the slot's filament id is the one field a custom profile travels in, and it holds eight characters on the printer. Bambuddy was putting an eighteen-character preset identifier in it whenever it could not find a real filament id. The printer stored the first eight and reported success, so the slot pointed at something that resolves nowhere: the slicer showed Generic and the printer's calibration table, keyed by the same field, no longer had a slot to key. Three printers in the support archive show it happening — an A1, a P1S and an H2D — so it was never specific to one model. Bambuddy now sends the slot's existing filament id, or the generic one for the material, both of which fit. Orca Cloud profiles were additionally never looked up at all, so theirs went in as a thirty-six-character identifier and fared worse still; they are now resolved like every other source. Also fixed on the way through: a failed Orca Cloud sign-in left its HTTP connection behind instead of closing it, which went unnoticed while only the settings page could trigger it and would have repeated on every spool assignment once the lookup above started using the same code. A profile that carries no filament id of its own — which is every profile created in OrcaSlicer, whose preset format has no such field — still cannot be told apart from the one it inherits from.
- Hovering a muted control in the light theme made its label vanish (#1909, reported by AntonPalmqvist) — the tab strip on the Print Queue page was where it got noticed: point at Batches and the word turned white on a near-white background. It was never about that tab. The light theme keeps
text-whitereadable by remapping it to the theme's foreground colour, but that remapping only ever matched the plain utility, not thehover:variant Tailwind compiles to a different selector — so roughly 400 controls across the app that dim their label at rest and brighten it on hover were brightening it to literal white, whatever the theme. They now follow the theme like everything else. Dark themes are unaffected, their foreground colour already being white, and the handful of buttons that turn a solid accent colour on hover read better for it rather than worse. - Every camera stopped working on 1.2.5.4 (#3001, reported by Jieper001, confirmed by JmanB52D and hikingthunder) — live view, snapshots, timelapse frames and the camera diagnostic all failed at once on every RTSP model — X1, H2 and P2 — with the in-app diagnostic reporting
capture_exceptionat 0 ms while network reachability passed at 1 ms. That 0 ms is the whole story: the failure happened before a socket was opened. The RTSPS proxy added in 1.2.5.4 finished by hanging its set of in-flight connection handlers on the server object as an attribute.asyncio.start_serverreturns anasyncio.base_events.Server, which has a__dict__and accepts that; under uvloop it returns auvloop.loop.Server, a Cython cdef class with no__dict__, which raisesAttributeErroroutright. Every launch path this repo ships — the Dockerfile,install/install.sh,deploy/bambuddy.service, the Windows service and the SpoolBuddy installer — pins--loop asyncio, added for #1896, so none of them selects uvloop and none of them could hit this. What broke is the installs running a unit file we did not write. The Proxmox VE Helper-Scripts LXC composes its ownExecStartwith no loop pinned, andrequirements.txtpinsuvicorn[standard], which installs uvloop on Linux, so uvicorn's default--loop autoselects it — that is the reporter's install and the two that confirmed it. Native installs created before the #1896 pin landed on 2026-07-05 are in the same position for a different reason:install/update.shnever rewrites the unit file, so a service written before that date has never been given the flag by any update since. Those installs are also still exposed to #1896 itself, where a truncated Virtual Printer FTP upload corrupts a.gcode.3mfsilently; the camera outage is simply the visible half. Hence a fix in the code rather than another flag in a unit file: the proxy now works on either loop instead of depending on the launch command to steer around it. The handler set now lives in a module-level registry keyed weakly by server, which both loops accept; keying it weakly rather than byid(server)means a proxy abandoned without a close takes its entry with it, instead of leaking one forever and eventually handing a new server a dead one's handlers once CPython recycles the address. A1 and P1 use the chamber-image protocol and return before the proxy is built, so they were never affected, and external RTSPS cameras caught the error and fell back to a direct connection, so they kept working without the TLS workaround. The reason the test suite could not see any of this is thatconftestbuilds its event loop from the default policy, so every async test in the repo runs on the selector loop — the one loop where the assignment was legal. The regression is now pinned twice: once by a test that drives the real function on a real uvloop loop, and once by a test that gives it a__slots__server, so the contract holds even where uvloop is not installed. The two external-camera teardowns were also switched toclose_tls_proxy, which #2968 introduced and left them out of, so they no longer leave handlers running past the server that owned them. - Some archived 3MFs lost their G-code when re-imported into the File Manager (#2993, reported via the in-app form) — they never lost it. The download serves the stored file byte for byte, and the G-code was still in the zip; what differed was who was asked. On the archive side the answer came from the file itself — the green GCODE badge reads the layer count and print time that were parsed out of the plate G-code — while the library decided from the filename alone, so a sliced 3MF stored as
Foo.3mfrather thanFoo.gcode.3mfcarried the badge and still came back as a source-only project with no Print button. That splits on how the print reached the printer, not on anything about the file — a slicer's LAN send names it.gcode.3mf, while a per-plate export or a cloud-dispatched print arrives as plain.3mf— which is why it looked random. Both sides now ask the same question of the zip itself, and every route into the library (upload, ZIP import, MakerWorld, external-folder scan) classifies on content rather than on the name. Files already in your library are re-checked once on the next start. The backend was always willing to print these, so this was only ever the interface refusing to offer something that would have worked; a genuine model file is unaffected, and one that now shows Print correctly stops offering Slice. - Swapping a spool left the previous spool's preset name on the AMS slot card — pull a Bambu ABS Orange out of A1, put a PLA Matte Dark Blue in, and the card still read "Bambu ABS" against the new colour. The backend had it right all along: the RFID auto-assign rewrites the slot's stored preset the moment the tag is read. The browser simply never refetched it. The slot card reads that stored preset ahead of the filament id the printer is reporting, so one cached row outranked correct data arriving over the WebSocket — and because every other field on the card (colour, material, fill, K value) rides the status push and updated instantly, it surfaced as a single wrong line rather than an obviously stale card. The manual assign path already refreshed it; the RFID path did not. Spoolman mode was the worse half of the same bug: its AMS sync writes that same row but announced nothing at all, so there was no event to refresh on — it now reports each slot it changed or cleared. Two further changes make the card right without waiting on any of that: the slot's queries no longer sit behind the 3-second cascade debounce meant for print completion (a swap touches one slot, and any further event restarted that timer), and the card now ignores a stored preset whose filament id disagrees with what the printer reports in the slot, so the correct name is on screen from the status push alone. A hand-picked preset name still wins wherever the stored row and the slot agree, and a user or local preset — whose ids genuinely cannot be compared — is untouched.
- A print that could not fetch its own 3MF could be charged another plate's filament (#2957, reported by doncaruana) — when the source file is missing, the usage tracker looks for a replacement in the library or in a previous archive and matched on the filename stem alone. That is far weaker evidence than it looks: Bambu Studio writes the printer-side filename from the project's
Titlemetadata, so every plate of a project arrives on the printer under one name however the file was renamed on disk. The reporter's single-filament job was handed a previous archive's three-filament plate and three spools were debited for material they never extruded, with nothing on the archive to say the numbers were someone else's. A candidate is now refused when it holds a different plate than the one running, and an all-plates export is refused unless it actually contains that plate — previously the plate was looked for later, found missing, and every filament in the file was summed onto one plate's print. Where the printer echoes only the 3MF filename and the plate cannot be known at all, which is the reporter's own firmware, the candidate is still accepted on its name and a warning now says so rather than the deduction happening silently. - A slow-but-healthy 3MF download was cut off at 30 seconds (#2957, reported by doncaruana) —
ftp_timeoutis handed to every download as both the socket inactivity timeout and the whole-transfer deadline, which makes its default a cap on how big a file a printer is allowed to serve. The reporter measured the same 5.4 MB 3MF at 45 s off a worn P1S SD card and 25 s off a new one, and a 15.15 MB 3MF at 105 s; an older 7.8 MB archive in his logs survived only because the transfer happened to finish inside the retry grace. None of those links were broken — they were slow, which is what the inactivity timeout exists to tell apart. The total deadline now follows the size the printer reports for the file, against the same pessimistic 25 KB/s floor the upload path has used since #2529. The extension is granted only once the printer has answeredSIZE, so a printer that is not answering at all still fails on schedule and the executor queue wait #2572 bounded is unaffected, and it is capped at five minutes because the print-start handler holds a pooled database connection for the length of its 3MF hunt. A transfer that overruns even that stretched deadline is not retried, for the reason an overrunning upload has not been since #2529: the retry would spend another full deadline reaching the same conclusion. - Bambuddy could run two heavy FTPS transfers against one printer at once (#2957, reported by doncaruana) — the reporter watched Bambu Studio itself lose its connection to a P1S while Bambuddy pulled a 12 MB 3MF, and a later log caught two Bambuddy downloads of the same 5,250,969-byte file overlapping during print start. A P1S at that moment is already serving the print off the same SD card and answering MQTT. Downloads now take turns per printer, the way uploads have since #2529. The gate is deliberately soft — a download that cannot have it within 30 seconds proceeds anyway, because a print losing its 3MF to queueing would be worse than the contention, and the printer file browser stays outside it entirely so a 3MF preview never waits out somebody else's multi-gigabyte selection — and it is now meaningful at all: the 90-second cap on a multi-path lookup used to return while its worker thread kept walking the remaining paths, still holding the printer's socket, so the walk is cancelled and waited out before the printer is handed on.
- The cover thumbnail re-downloaded a 3MF another part of Bambuddy had just fetched (#2957, reported by doncaruana) — the cover endpoint and the print-start archive flow share a 3MF cache so whichever fetches first hands the file to the other (#972), but the cover endpoint consulted it once on the way in and then retried for up to two and a half minutes without ever looking again. In the reporter's log the archive flow published the file 42 seconds into that sequence and the cover's third attempt still pulled its own full copy of it, off a printer that was mid-print. It now looks again before each retry, and a file it picked up that way is left alone rather than re-registered under its own name or deleted on the way out — it belongs to the archive flow.
- A camera snapshot occasionally logged an asyncio ERROR with a traceback into the camera code (#2968, reported by ceasley) —
Task was destroyed but it is pending!, namingcreate_tls_proxy.<locals>._handle(), once every few hundred snapshots. Nothing was actually broken by it — the snapshots on either side of each one succeeded — but it is the loudest thing in an otherwise clean log and it reads like a camera fault.asyncio.start_serverwraps the connection callback in a task and keeps only a weak reference to it, so the RTSPS proxy's handler could be collected while still waiting on its two forwarders; that message is the garbage collector noticing. Teardown had the matching gap:server.close()stops the listener but leaves established connections running, so the close waited on a handler that only finishes when the peer drops the socket — and by then ffmpeg has already been reaped, so the connection is dead weight nobody is going to close. The proxy now holds its handlers for as long as they run and cancels them at shutdown, which is whatServer.close_clients()would do natively if it had not landed in Python 3.13 (Bambuddy supports 3.10). Both the snapshot path and the streaming endpoint go through the same shutdown. 7 regression tests, against a real TLS listener rather than a mock, since the bug is in socket teardown. - Deleting a print with no 3MF left its timelapse and its uploaded source on disk (#2968, reported by ceasley) — and logged the ordinary case as a security incident. An archive created without a 3MF carries an empty
file_path, and both delete paths derived the directory to remove from that path: finding nothing, they removed nothing and wroteSECURITY: Refusing to delete files for archive 7 - file_path is empty or invalid: ''at ERROR. That was true when such an archive really was an empty row, and stopped being true once one could own files —<archive_dir>/<id>/holds its timelapse and finish photos, andarchive/no_source/<id>/holds a source 3MF uploaded onto it afterwards. Neither was ever removed, so the row went and the video stayed. That is not a corner case on an H2-series or P2S printer, where a print sent from Bambu Studio always archives without a 3MF — it is most of the library, and the space it used never came back. Both are now cleaned up, but by different means, because<archive_dir>/<id>shares a namespace with the per-printer folders: a normal archive lives at<archive_dir>/<printer_id>/<timestamp>_<name>/, soarchive/1is printer 1's folder and the directory the shared path helper hands archive id 1 — and archive ids and printer ids are small integers from unrelated sequences, so on every install the first few archives collide with the printers. A recursive delete there would take every print that printer ever made. Soarchive/no_source/<id>/, which is nested a level deeper under a name no printer id can take, is removed whole, while the id-named directory gives up only the files the row itself names — itsphotossubdirectory and its recorded video — and is then removed only if that left it empty, which a printer folder holding prints never is. Anything unrecognised keeps it alive and is left behind rather than guessed at. The same collision is now refused for corrupted rows: nothing one level under the archive directory is deleted at all, and since an archive directory has been two levels deep since the first commit, afile_paththat has lost a path component can no longer point the delete at a printer folder. The shared<base_dir>/photosdirectory that every no-3MF archive once wrote into is never a candidate either, because removing it on one delete would take the others' photos. Hard delete had its own copy of these rules — the helper it was extracted from says in its docstring that it exists so the two cannot drift apart, and they had — so it now shares them, which also means it stops skipping the print-log thumbnail cleanup when a guard trips. TheSECURITY:wording is kept for the two conditions that really are one, and both still remove the row so an archive cannot get stuck in the UI. 20 regression tests. - Every ffmpeg failure logged its build banner instead of the error (#2968, reported by ceasley) — ffmpeg opens each run with about twenty lines of version, build and library banner and prints its diagnosis last. Seven of the eleven places Bambuddy logs ffmpeg or ffprobe output kept the first 200 to 500 characters of that, so the banner survived and the error did not. The reporter's log carried twelve camera-capture errors and every one of them read
ffmpeg frame bytes capture failed (code 183): ffmpeg version 7.1.4-0+deb13u1 … built with gcc 14 … configuration: --prefix=/usr --extra-version=— identical on every install, and silent about why the capture failed. The exit code was the only usable information in the line. The banner-stripping summariser written for the camera streaming endpoint (#925) now lives in a shared module and every ffmpeg and ffprobe stderr goes through it: snapshot capture, last-frame extraction, the layer-timelapse stitch, the archive's MP4 conversion, external USB and RTSP capture and streaming, and timelapse post-processing. It drops the banner, keeps the last ten meaningful lines — the error plus the input analysis that explains it — and leaves indentedDuration:andStream #0:0lines alone, since those are diagnosis rather than boilerplate. Two things the scattered copies also got wrong. ffmpeg echoes its input URL back in its error output, and seven of those call sites logged it without masking, publishing a printer access code or an external camera's password into a log file people routinely attach to public issues; masking is now part of the summary, applied to the whole string before anything is dropped, so a credential straddling the cut cannot leave its tail behind. And ten sites called a bare.decode()on bytes ffmpeg had copied stream fragments into, which could raiseUnicodeDecodeErrorwhile reporting an unrelated failure — the summariser takes bytes and decodes them safely. A failure whose output was banner only now saysno diagnostic outputrather than trailing off after the colon, and the whole line is capped, since ffmpeg quotes back what the peer sent it. 20 regression tests, including one that fails if a twelfth call site starts truncating or decoding by hand. - Every print archived from a Bambu slice had no bed temperature, so preheat guessed one (#2989, reported by senguendk) —
PrintArchive.bed_temperaturewas parsed by looking for abed_temperaturekey in the project settings. BambuStudio does not write one: it stores a separate per-filament array for each plate type —cool_plate_temp,eng_plate_temp,hot_plate_temp,textured_plate_temp,supertack_plate_temp— and names the plate the project is sliced for incurr_bed_type.bed_temperatureis the Orca/PrusaSlicer spelling, so the lookup matched nothing at all: 0 of 455 real 3MFs on a live install produced a bed temperature, and every archive stored NULL. That is why preheat logged "archive has no bed_temperature metadata" on jobs sliced perfectly normally, and then heated the bed to the configured keep-warm temperature — 90°C by default — for prints that asked for 55°C. The extractor now reads the array the fitted plate points at, taking the first-layer value in preference since that is what the printer heats to before the print starts; the same 455 files now all resolve, across the four plate types they were sliced for. The plate names and the plate-to-key mapping are BambuStudio's own, so a plate a future release adds reads as unknown rather than silently taking another plate's temperature — as doesDefault Plate, which BambuStudio maps to no key. The per-filament array resolves to its highest entry rather than its first: the bed has one temperature and a 0 in that array means "this filament cannot print on this plate", so the first entry would record a cold bed for any project whose first filament is not one the fitted plate is heated for. An array that is all zeros is left unrecorded rather than stored as 0. Orca-exported 3MFs keep parsing through the generic spelling, which is now the fallback rather than the only thing tried. Archives you already have are repaired too: a one-shot pass at startup re-reads the 3MF still on disk for every archive whose bed temperature is blank, so preheat gets the right figure when you reprint an old job from the queue rather than only on new ones. It fills blanks and nothing else — a temperature already recorded is never overwritten, an archive whose file is gone stays blank, and a corrupted 3MF is skipped rather than failing the boot. The plate mapping is now shared between the ingest path and the repair so the two cannot read a 3MF differently. A dead copy of the old, wrong lookup that nothing called was removed at the same time, so it cannot be wired back up by accident. 36 regression tests. - A manual K-profile calibration left a print in your archive — Bambuddy already recognises the printer's automatic pressure-advance run,
auto_pa_line_calib_mode, and skips archiving and notifying for it. Started by hand instead of automatically before a print, the same calibration reports under its own name with noauto_prefix — and manual flow dynamics has two shapes, a line and a pattern, so neitherpa_line_calib_modenorpa_pattern_calib_modematched anything. Both arrive exactly the way the automatic one does — a bare subtask name with no/usr/path — which meant the archive path swept FTP for a 3MF that cannot exist and then wrote a no-3MF archive named after the calibration, on a printer that was in the middle of calibrating. Both are now on the same list, which is the one place both the print-start and print-complete callbacks consult. Matching stays exact after normalising path, suffix and case, so a file you deliberately namedpa_pattern_calib_mode_v2.3mf— or your ownpa_bracket.3mf— is still archived as the print it is. Wiki updated. - A spool you assigned to an AMS slot unassigned itself seconds later (#2987, reported by frethop) — and the slot's colour changed at the same time. It looked like Bambu Studio and Bambuddy fighting over the slot; the reporter's log shows Bambuddy losing to itself. P1S firmware 01.10.00.00 reads every lowercase hex letter in an AMS
tray_coloras a zero, and hides it completely: the command response echoes back the value you sent and reportsresult: "success", so only the next AMS push reveals what was really stored. The spool-assign path sentspool.rgbaverbatim, and that column stores lowercase — so09ff00ffbecame09000000on the printer andff5100ffbecame00510000, while the one uppercase write in the same window round-tripped intact. That is the visible colour change. It is also what deleted the assignment: the auto-unlink sweep asks whether the slot still matches the spool it is assigned to, the mangled colour no longer did, and the assignment Bambuddy had created four seconds earlier was removed. Re-assigning could not help, because the Configure Slot dialog seeds its colour from whatever the printer currently reports — so it wrote the mangled colour straight back and cemented it, which is the loop in the report's steps 4 and 5. Colours are now uppercased at the single point the MQTT command is assembled rather than in each of the four routes that configure a slot, because a caller that forgets is exactly how this arrived. Nothing else about the command changes: no padding, no invented alpha, andtray_type/tray_sub_brandskeep their case, where it carries meaning. Two more things found in the same log. A spool with a brand but no subtype was configured with the literal stringNonein its name —"Sunlu PLA Matte None"went on the wire, because the branded branch interpolated the subtype without checking it while the unbranded branch guarded it. And the FTP log is now readable: a426whose bytes Bambuddy has verified against the printer is how Bambu's FTPS normally ends a transfer, not a fault, so it is logged at INFO instead of WARNING. It fired 54 times in this one bundle, every single one followed by a completed upload, and it was burying the 26 TLS handshake failures in the same log that actually cost the reporter two prints. A426whose bytes do not verify is still an error and still fails the upload. 24 regression tests. - One ASA spool parked in the AMS added 20 minutes to every PLA print (#2886, reported by FirstRulez) — Preheat's chamber target was the maximum across every loaded AMS tray, with no reference to the job. The reporter's P2S holds PETG Pro, PLA, ASA and PETG; the ASA row of the filament map says 45°C, so a PLA-only plate was dispatched with
chamber_target=45°C, the bed driven to 90°C to reach it, and the full 900s max-wait plus 300s soak burned before the upload even started — every time, because a P2S has no chamber heater and the chamber tops out around 33°C, so the wait can only ever end on the timeout. Their log carries fifteen of these. The intent was never in doubt: the resolution order documented one screen above reads "PLA-only print derives 0 → chamber phase auto-skips", but it was implemented as PLA-only AMS rather than PLA-only print, and only misfires on a mixed-material load. The derivation now reads the trays the item'sams_mappingactually names — the same array the print command puts on the wire,[-1, -1, -1, 1]in their case, addressing exactly the PLA slot — so the ASA two slots over contributes nothing and the stage skips outright. Multi-material prints are unaffected: the maximum is still taken, just across the trays the plate loads, so an ASA the print really does use is still the binding constraint. An item whose mapping is missing or still unresolved keeps the whole-unit scan, since that is the only signal left and narrowing to nothing would disable preheat for prints that need it. The bed hold between jobs (queue_keep_bed_warm) is gated on the same derivation and was holding beds at 90°C for the same wrong reason; it now reads the next item's mapping too. The external spool is no longer invisible to this: the scan only ever looked atraw_data['ams'], so an ASA print fed from the external feed derived 0 and got no preheat at all — a mapping that names 254/255 is now honoured, while an item with no mapping still derives from the AMS alone so nothing starts preheating that did not before. That covers the mappings Bambuddy builds itself, from the print dialog or the dispatcher's own matcher. It does not cover a mapping captured from a slicer through a Virtual Printer: BambuStudio writes the external spool as-1there, which is the same value it writes for a slot the plate does not use, so the two cannot be told apart. A wholly external one carries no usable mapping and falls back to the whole-unit scan as before; a mixed one derives from its AMS trays and the external half stays unread — which is exactly what it did before this change, since nothing ever readvt_tray. 29 regression tests, built from the trays and mapping in the reporter's own support bundle. - Spoolman reset your renamed extra fields on every restart (#2983, reported by ngreatorex) — Bambuddy checked whether one of its four custom spool fields existed by calling
GET /field/spool/{name}. Spoolman has never served that: its API declares onlyPOSTandDELETEat that path, so the check answered 405 Method Not Allowed every single time and could never succeed. Each call then fell through toPOST /field/spool/{name}— and that endpoint is an upsert, not a create. It answers 200 whether or not the field is already there, so a field you had renamed, retyped or given a default to in Spoolman's own UI was silently reset to Bambuddy's version of it, and an untrueCreated Spoolman extra fieldwas logged alongside. The reporter's log carried 60 of those lines in three days. Existence now comes from the documentedGET /field/spoollisting, matched on the field'skeyrather than its displayname— a rename is the same field, and treating it as a missing one is what caused the overwrite. An existing field is left completely alone. You can now rename these fields in Spoolman and the name will stick. Registering all four also costs one request instead of four, and none at all on a client that has already looked once. If the listing can't be read at all, Bambuddy falls back to attempting the write exactly as before, so an unexpected Spoolman build is no worse off than today. - The internal slicer picked PETG for a PLA plate, and an A1 process for a P1S (#2982, reported by Igiegel) — Both traced to one line of the slicer sidecar: its bundled-profile listing read
filament_typeoff the leaf preset and never reportedcompatible_printersat all. Measured against the shipped trees, the leaf read finds nothing — zero of the 1156 filament presets in OrcaSlicer 2.4.2 carry a material, and zero of the 1792 in BambuStudio 02.08.02.61 — because it lives one to four hops up theinheritschain (Bambu ABS BBL A1→Bambu ABS base→fdm_filament_abs). With no material on any of them the pre-pick had only colour and source to go on, so a white PLA plate drewBambu PETG Basicon an A1 mini andBambu PCon a P1S. The sidecar now walks the chain, which recovers the material for 1124 of the 1156 (the other 32 name a parent Bambu's own bundle does not contain, and stay listed with no material rather than being dropped).compatible_printersis the second half, and it is the only truthful account of which printer a preset belongs to: the bundle ships no process preset named after a P1S, an X1, an X1E or an H2D Pro — all ten of the P1S's are namedBBL X1Cand name the P1S only in that list. Reading the printer out of the preset name therefore made a P1S look like it had no compatible process at all: all 198 were hidden behind “Show all” and the auto-pick fell through to an alphabetically-first0.06mm Fine BBL A1 0.2 nozzlethe CLI then refused with “the selected printer is not compatible with the process preset”. A P1S now gets the0.20mm Standard BBL X1Cit should always have had, and 73 filaments instead of 4. This half needs the updated sidecar image — an older one simply reports nothing and Bambuddy stays on the name matcher, degraded exactly as before rather than broken. Three hardenings ride along so a stale sidecar fails more gracefully: a filament preset that states a different material than the plate asks for is now skipped outright rather than merely scored down (a preset stating no material stays eligible — unknown is not wrong), a slot is no longer held on printer-compatibility alone once its material turns out to disagree (a preset you chose yourself is exempt — printing PETG on a plate labelled PLA is a legitimate thing to do), and a dropdown the printer filter would empty now shows everything instead, since a visible preset for the wrong printer can be changed and an empty list cannot.filament_colourstays null throughout, which is correct: no BBL profile carries a colour at any depth, because colour is a spool attribute rather than a profile one. - Every slice that didn't name its own process quietly got the slowest one the slicer ships — Found while tracing #2982. Within a tier the preset list is alphabetical, and Bambu's naming puts the finest layer height first, so the auto-pick landed on
0.08mm Extra Finefor an X1 Carbon and0.06mm Finefor an A1 mini. Correct presets, but nobody's idea of a default. Among candidates that are equally valid for the selected printer, the one nearest 0.2mm now wins —0.20mm Standardwhere it exists, the closest thing to it otherwise, ties breaking toward the coarser and therefore faster height. A preset whose name carries no readable height is still pickable when it is the only candidate, and a process the 3MF named still overrides all of this. - An H2D Pro classified every bundled preset as another printer's — Also found while tracing #2982, and the same shape as the A1 mini's
A1Mrename (#1649): the bundle names H2D Pro presetsBBL H2DPwhile the printer preset, and the model registry with it, spells the modelH2D Pro. The alias table now carries the pair. It stays deliberately narrow —H2DPand a plainH2Dare still different machines and must not collapse. - A printer card said "Unknown stage (72)" where it now says "Preparing" — New models report stage numbers before Bambuddy learns their names, and the H2C still has several. Until now those reached the card verbatim, as a number that means nothing to the person reading it, on a line that otherwise names what the printer is doing. Every stage that has turned out to be unnamed so far has been part of the run-up to printing, so an unnamed one now reads as "Preparing" — the same label stage 74 already carries, rather than a second spelling of the same idea. The substitution is display-only and deliberately not pushed down into
get_stage_name: that function also feeds the stage-transition log line and the once-per-session warning that exists precisely to capture unnamed stages so they can be named in a later release, and there the number is the entire diagnostic value. Both paths are pinned by tests that assert they disagree for an unnamed stage and agree for a named one, so a future tidy-up cannot quietly collapse them and blind the thing that reports these. The idle sentinels (255 on A1/P1, -1 on X1) still resolve to no stage at all rather than being swept up as unnamed. - An AMS slot card showed a multi-colour spool as one flat band (#2967, reported by NeighborGeek) — A Ziro "Colorful Mist" — yellow, cyan and pink, effect Tri Color — hovered on the printer card as a single pink rectangle, because a printer reports exactly one
tray_colorhex per tray and nothing else. Telemetry cannot describe a gradient or a surface effect and never will, so the header now paints the spool's own swatch whenever the bound spool carries extra colour stops or an effect, through the same builder the Inventory swatches use — the two surfaces cannot drift because there is one implementation. A plain single-colour spool keeps the flat colour it has always had, so the common case goes nowhere near the gradient path. Any stop at all counts, not just two: the colour layer ignores the base hex the moment stops exist, so a one-stop spool renders that stop rather than the slot's hex, and honouring it is what keeps the card agreeing with Inventory. The colour name and the print dialog's slot dropdown were the other two halves of the report and were already fixed on dev by #2875 and the slot-naming change that landed the day after it was filed. Spoolman mode gained the gradient in the process: Spoolman holds the extra stops inmulti_color_hexesand the label renderer had been reading them for releases, but_map_spoolman_spoolnever returned them, so the identical roll registered in Spoolman rendered flat while the internally-managed one did not. Both now share one parser rather than reading the same field two ways. The asymmetry that remains is Spoolman's own and is pinned by a test rather than left to be rediscovered: it has no field for a surface effect — its only neighbouring field,multi_color_direction, describes how the stops are laid out, not that the roll is silk — soeffect_typeis None there instead of guessed at. The colour name moves onto the same scrim the vendor badge already uses once the background has more than one band, because a single hex cannot decide legibility across yellow, cyan and pink and the label sits dead centre where the background is likeliest to change under it; a single stop or an effect over one colour leaves a real base colour to test, and keeps the contrast rule it had. Wiki updated. 19 backend and 8 frontend regression tests. - Everything the internal slicer produced was Bambu green, whatever filament was picked (#2977, reported by fadudba) — A slice through Bambuddy's own slicer came out with
filament_colour = #00AE42every time: a green plate thumbnail regardless of the profile chosen, and a Color mismatch in the Print dialog against the AMS slot the job had just been correctly mapped to. The reason is that a colour is not a property of a filament preset in either slicer — it belongs to the project, and their GUIs set it from the plate — so nothing was attached to the preset Bambuddy sent by name and the CLI fell back to its own compiled-in default, which is Bambu green. Verified against a 02.08.02.61 sidecar with the reporter's exact triplet: as sent,['#00AE42']; with a colour written onto the same profile, the colour asked for. Each filament row in the slice dialog now carries an editable colour swatch, and the colour reaches bothproject_settings.configandslice_info.config, which is what the thumbnail and the AMS mapping actually read. The swatch is pre-filled from the colour that slot was designed with — read from the source 3MF's own project settings — then the preset'sdefault_filament_colour, then the slicer's green. It is offered on single-filament sources too, because an STL, and equally a mesh-only 3MF exported from CAD, has no colour anywhere else to inherit; measured, a colourless source recordscolor="#00AE42"in its own slice info, so a fallback that read the last sliced colour rather than the designed one would have been circular.default_filament_colouris deliberately not treated as the answer on its own: the CLI never reads it — a profile carrying only that still slices green — it is consumed by the GUI when a project is created, so it is read and rewritten asfilament_colour, which the CLI does honour. Bambu's bundled filament profiles define it nowhere at all (zero occurrences across the whole shipped tree), which is why it can only be one link in the chain. A slot the user did not touch and that has no designed colour submits an empty string rather than the swatch's displayed default, because a sent colour outranks the preset's own and pinning the placeholder would silently discard the real colour of an imported OrcaSlicer profile that carries one. Slicer Pipelines pick up the same chain without carrying a swatch of their own. The control sits beside the filament dropdown, styled like it and the same height, showing the swatch and its hex together. That placement is the third attempt and the first that reads as a control: a bare swatch in the label row looked exactly like the read-only dot multi-colour rows had carried for releases, and adding the hex beside it only made it look like a caption on the label — so on a single-filament STL, the one source with no colour to inherit and therefore the case the control exists for, nothing suggested anything was settable. The swatch and the hex are wrapped in one label bound to the input, so a click anywhere on it opens the picker rather than only a 16px dot being live. The colour is also painted on the input directly rather than relying only on the browser's native colour-swatch pseudo-element, since an unlit swatch is indistinguishable from no swatch at all. Translated in all 14 locales, wiki updated, and covered by 38 backend and 14 frontend regression tests. - A filament preset the slicer could not resolve was sliced as PLA at 200 °C without saying so — Found while investigating #2977. Bambuddy names the preset to inherit and the sidecar resolves it against its bundled profile tree; when that tree does not contain the name, nothing rejects it. The CLI inherits nothing, falls back to its compiled-in defaults for every field, and returns a well-formed success — so a PETG preset whose name a sidecar image predates prints at PLA temperatures with no diagnostic anywhere. Measured against a 02.08.02.61 sidecar: an unresolvable name slices as
filament_type ["PLA"]atnozzle_temperature ["200"]withfilament_ids [""]andfilament_vendor ["(Undefined)"]. Bambuddy now recognises that pair and logs a warning naming the slot and the preset that was picked, pointing at the sidecar image as the fix. The file is kept rather than refused, unlike the missing start G-code of #2838: this one prints, it is only wrong, and someone may well be slicing deliberately with a profile their sidecar predates — so the choice is theirs to make with the temperatures in front of them. Both signals are required together, which is what keeps it from firing on the two legitimate cases that look similar: a hand-written profile that simply never named a vendor still carries a real filament id, and a user's own cloud preset carries a vendor while legitimately having no bundled id. - A failure reason the backend derived and the same reason a user picked counted as two different reasons (#2974, reported by ojimpo) —
failure_reasonwas written in three vocabularies and nothing reconciled them in storage. The backend wrote English display labels ("Layer shift"), older builds of the archive editor wrote the translated label in whatever locale that user was running, and two stale-archive paths wrote English prose sentences ("Stale - reconciled after reconnect, end time unknown"). All three land in one column — the PATCH route has mirrored the field onto the latest print-log entry since #1444 — and the Failure Analysis widget groups on the raw value, so one real cause occupied several buckets. Measured on a live install before this landed: 91 rows reading"User cancelled"beside 1 reading"userCancelled". In an English UI those render as the same words twice with different counts, which is why it went unnoticed; in any other locale one of the two stays English, because a stored label has no key fort()to resolve. The editor was worse than cosmetic about it: its reverse lookup compared the stored value againstt(...)in the current locale, so for a non-English user nothing matched, the dropdown opened empty over an archive that plainly showed a reason, and saving from that state wrote the empty selection over the stored text. One vocabulary now. The keys were already canonical and already enforced —_FAILURE_REASON_KEYSinapi/routes/print_log.pyrejects anything else with a 400, and says why in its own comment — so this isderive_failure_reasonbeing brought in line with a rule the rest of the stack had been keeping._HMS_FAILURE_REASONSstores keys, the cancel branch returnsuserCancelled, and both stale paths write a newnoStatusUpdatekey rather than prose; which of the two stale situations occurred is already carried bystatus, so collapsing them loses nothing and gives Statistics one bucket instead of two untranslatable sentences. Existing rows are converted, which was the open question on the issue: a startup migration folds 168 historical labels onto the 12 keys across both columns. It is exact rather than a guess — every label across all 14 locales resolves to exactly one key, verified with no collisions — and the map is a frozen snapshot rather than something read from the locale files at run time, because it maps what was written historically and regenerating it would silently stop recognising the very rows it exists to convert. A value outside the map is deliberately left alone; guessing at it would be worse than leaving one honest string in its own bucket. The migration carries no one-shot settings flag on purpose: it only matches values in the map and a key is never a label, so it is self-terminating, and a flag would permanently skip anyone restoring an older database. The editor no longer destroys what it cannot read — an unrecognised value keeps its own option in the dropdown and survives a save, instead of initialising to empty and overwriting the stored text. Covered by 13 migration cases across both tables, including the live 91-vs-1 split, both stale sentences, free text left untouched, and running twice changing nothing; plus a test asserting every value the backend can derive is a key the rest of the stack accepts, so a display label cannot creep back into the map the way it did before. NewnoStatusUpdatelabel translated in all 14 locales. - Every page was a blank white screen on iOS 16.0-16.3 (#2971, reported by zevulos) — An iPhone on iOS 16 loaded nothing at all: no error, no partial render, just white, over LAN IP and over an HTTPS domain alike, while the same install was fine on Android, macOS, Windows and Linux. The cause was one regular expression.
remark-gfm, added in v1.2.5 for the folder README panel, reachesmdast-util-gfm-autolink-literal, whose module body contains a lookbehind assertion —(?<=— that Safari did not support until 16.4. A regex literal is validated when its module is compiled, not when the function holding it runs, so this was never going to fail as a broken README panel:FolderReadmePanel->FileManagerPage->Appis a plain static import chain, the regex landed in the entry chunk, and the browser refused to compile all 10 MB of it. Nothing executed, so nothing rendered. Every install from v1.2.5 onward has been unusable on those iOS versions, and v1.2.4 is the last release that loads on them. The fix. The panel now renders GFM through a locally composed plugin that registers four ofremark-gfm's five sub-extensions — tables, strikethrough, task lists and footnotes — and omits autolink literals, which is the only one that carries the lookbehind. Composing rather than configuring is forced by the nature of the bug: importingremark-gfmat all is what breaks the page, so no runtime option could have reached it. The visible cost is that a barehttps://example.comorfooexample.comtyped into a folder README no longer turns itself into a link;[text](url)and<https://example.com>are core markdown and still do.remark-gfm,mdast-util-gfmandmicromark-extension-gfmleave the dependency tree, and the bundle is 23 KB smaller. Why the build never said anything. Vite'sbuild.targetgoverns syntax lowering, and esbuild does not rewrite regular expressions — measured here, a lookbehind builds silently undersafari15,safari16.0andes2020alike, which is exactly how this shipped and then sat unnoticed for two months. So the guard is a real check rather than a compiler setting:npm run buildnow ends incheck-browser-baseline.mjs, which scans the emitted bundles for syntax that Safari 16.0 cannot parse and fails the build with the offending snippet and the dependency-hunting command. It is deliberately scoped to parse-time failures only — a missing runtime API breaks one feature, while one of these takes down the whole app, and there is no graceful degradation to fall back on. Covered by seven renderer tests that pin both halves of the trade: each surviving GFM feature still renders, and the two forms of autolinking stay off on purpose so a future dependency bump cannot quietly bring the lookbehind back. - The print dialog named an AMS slot after the wrong spool — The slot dropdown described every slot from the printer's own telemetry, and the printer cannot describe a spool it did not sell: a tray record has no brand field,
tray_sub_brandsis left empty for anything that is not a Bambu spool, and the colour is a bare hex the client resolves against Bambu's colour catalogue. A Devil Design PLA Basic Orange assigned in Bambuddy therefore read as "PLA (Sunflower Yellow)" — Bambu sell a Sunflower Yellow at the sameFEC600— while the printer card, which reads the assignment, named it correctly. The two views now agree:GET /printers/{id}/inventory-remaincarries each bound slot's brand, material, subtype, colour name and hex alongside the pooling key it already sent, and the dialog prefers that over telemetry, falling back field by field so a spool with no stored colour name still gets the catalogue lookup it had before, and re-reading it on every open so a spool assigned moments earlier is named correctly straight away. Resolved server-side, so internal inventory and Spoolman mode answer identically rather than the client re-deriving a rule that differs per mode. Matching is deliberately untouched and still runs on the printer's telemetry, so the auto-assignment and the colour-mismatch warning cannot start disagreeing with what the dispatcher does. - A K profile could be saved against the wrong hotend, and applied to the wrong one — Which nozzle an AMS slot feeds, and how wide it is, was worked out independently in seven places, each reading the printer's first nozzle entry for every slot on the machine. That is correct on a single-nozzle printer and on a dual-nozzle printer with matching nozzles, and wrong the moment two sizes are fitted: the K profile for the other hotend was looked up, and with the per-model presets above the wrong preset would have been too. The resolution now lives in one place, and which array entry belongs to which hotend is no longer inferred — measured on an H2D fitted with a 0.4 on the left and a 0.6 on the right, the first entry reads the right hotend, so the array is indexed by extruder id. Separately, the spool form identified a chosen calibration by
cali_idxalone, and the printer numbers its calibration table per nozzle — on a dual-nozzle printer the same index exists on both hotends meaning different things, so saving could persist the other hotend's K value and nozzle diameter. Each hotend is now keyed by printer, extruder and diameter throughout, which also lets a 0.4 and a 0.6 profile for the same hotend coexist — something both K tables could always store but the picker could not express. SpoolBuddy's write-tag page carried a verbatim copy of the same lookup and gets the same fix. - RFID auto-assign picked a K profile without checking which hotend it was calibrated on — The first stored profile matching the printer and nozzle size won outright, with no extruder test at all. On a dual-nozzle printer a spool calibrated on both hotends therefore had a coin toss decide which pressure-advance value the slot got, on the path that runs unattended every time a Bambu spool is loaded.
- Linking a Spoolman spool by tag configured the slot as generic filament — That path resolved no slicer preset whatsoever and went straight to the generic material id, so a Spoolman spool with a preset set in inventory lost it the moment it was linked by tag. The same defect #1713 fixed on the assign path, in the function next door.
- Moving an AMS to the other nozzle re-selected the K profile for the wrong one — When a Filament Track Switch moves an AMS between inlets, the slot's K profile is re-selected for the nozzle it now feeds. It was resolved against the printer's first nozzle rather than the one the AMS had just been moved to, which on a machine with two different sizes fitted is the wrong nozzle by construction.
- Reading a printer's calibration table could stall for 20 seconds — H2-series firmware answers only the first one or two of a concurrent burst of calibration requests and silently drops the rest, each dropped request costing a five-second timeout before its retry: measured at 11 and 23 seconds on an H2C and an H2D for four parallel requests, against roughly one second sent in series. Sizes are now requested one at a time, while the printers themselves are read in parallel since separate machines are separate connections. An X1C answers all four at once, which is why this only ever surfaced on dual-diameter printers.
- A print archived without its 3MF never got its timelapse, and on a short print could be given somebody else's (#2957 follow-up, reported by doncaruana) — The reporter confirmed the #2957 archive recovery worked and then noticed the timelapse was not recovered with it, "even though it's there".
_capture_timelapse_baseline_at_startsays in its own docstring that it must be called from everyon_print_startpath that proceeds to a real print, and what breaks otherwise: the completion scan falls back to snapshotting the card after the printer has written the video, so the new file lands inside the baseline and no diff can ever match.on_print_starthas three such paths and the no-3MF fallback branch was not calling it — nothing else covered the gap either, becauseon_print_running_observedis restart-recovery only and is suppressed wheneveron_print_startfires. So every fallback archive reached completion with no baseline in memory and none on the row, and kept its timelapse only by accident. Not confined to the reported cool-off case: the same branch serves the internal-storage verdict, so every H2C/H2D/P2S print that Bambu Studio's Print button sends to eMMC lost its timelapse the same way, on a card that was holding it the whole time. Measured on a live install before the fix: 0 of 9 fallback archives had a baseline, against 73 of 275 normal ones. The branch now takes the baseline like the other two, placed last as they are so a slow card cannot delay the active-print registration, the energy reading, the archive-created event or the start notification ahead of it. The second half is the short-print case. Under the five-minute FTPS cool-off the card is still unreadable when the baseline has to be taken, andlist_files_asyncanswers[]when its connect fails rather than raising — indistinguishable from a card holding no videos. When the cool-off then expired inside the 900-second poll window, every video on the card read as new, the first in listing order won, and a stale unclaimed video was attached to this print and deleted off the printer. The empty baseline is still recorded rather than refused, deliberately: Bambuddy deletes each video once it is attached, so the usual card holds exactly one video at completion and an empty baseline resolves it correctly — refusing outright would have lost that common case to protect a rare one. Instead the scan marks such a baseline untrusted and the attach step declines to choose between several candidates, leaving them on the printer for the manual Scan for Timelapse button. Five regression tests covering both halves and the single-video case; full backend suite green (11230). - Load and Unload in the AMS slot menu did nothing on a printer with a Filament Track Switch — The command that loads a slot carries an optional
extruder_id, and Bambuddy never sent it. That is correct on every printer without the switch, and it is what BambuStudio does there too: each AMS is wired to one hotend, so the firmware works the target out for itself and an explicit value would only be a guess at something it already knows. Fit the switch and that stops being true — every AMS is then bound to one of the switch's two inlets instead of to a hotend, either hotend is reachable from any slot, and a load command that names neither leaves the firmware with nothing to act on. It was dropped in silence. Bambuddy now asks which nozzle to feed, in the same place and on the same terms as Bambu Studio: a small dialog with the two hotends, no preselection so an absent-minded Enter cannot feed the wrong one, and the hotend already fed from that very slot greyed out. Printers without a switch are untouched and still load in one click, because the field has to stay absent there. If the switch has been fitted but not yet set up on the printer — any AMS still not assigned to an inlet — the load is refused up front with a note to finish Manual AMS Setup, rather than sending a command that would go nowhere. Unload was addressed at the same time and for a related reason: it was aimed with the printer's singletray_nowfield, which names one slot for the whole machine, so on any dual-nozzle printer with both hotends loaded it unloaded whichever of the two that field happened to name regardless of which slot's menu was used. It now names the slot, and on a printer that has reported two hotends the one actually fed from that slot is resolved from the per-hotend loaded-slot report; a slot no hotend is holding is reported as such instead of unloading something else. Single-nozzle printers are untouched — with one hotendtray_nowis already unambiguous, so there is nothing to resolve and nothing to get wrong. AMS-HT slots are untouched too: an HT unit is addressed by its unit id rather than by AMS-and-slot, which these commands do not accept, so unloading one still falls back to the printer-wide form it always used. - A batch order whose queued runs were deleted became a card that could neither be queued nor closed (#2960) — An order queues the runs it owes by cloning an existing queue item for the same plate: that row is the only record of the printer target, AMS mapping, filament overrides and print options the user chose, and there is nothing else to copy them from. Deleting it left the order still reporting the run as outstanding, with Queue remaining answering "Plate 1 has no queued or finished run to copy settings from" every time, and no way out — the Cancel action was hidden unless the order had pending items to cancel, which by then it had none of. Queue a multi-plate file, change your mind, remove the rows, and the Batches tab kept a card that did nothing forever. Deleting an order's last surviving run for a plate now cancels it instead: a cancelled run does not satisfy a target, so the order still says it owes the print, and it can still produce it. That is the design the feature already documented for cancelled runs — the delete path simply never took part in it. A run that has completed is exempt and is still deleted outright, because rewriting a finished run as cancelled would falsify what the order actually produced, and a plate with any other surviving run is untouched, so this only ever engages on the last one. The queue and the Clear History action both say what happened rather than reporting a delete that did not occur. Independently of that, the card no longer offers actions that cannot work: the response now reports, per plate, whether anything remains to clone from, so a stranded plate explains itself instead of presenting a button whose only outcome is an error toast, and the header button offers only the runs that can actually be queued. Dispatching an order with one stranded plate now queues every other plate instead of aborting the whole order on the first one it cannot clone — an explicit single-plate request still fails loudly, and names the plate the way the Batches tab does rather than by a bare index. Cancel is offered for any active order, since closing one out is exactly what an order with nothing left pending needs. Existing stuck orders are not rewritten: they report honestly that their runs cannot be re-queued, and Cancel now closes them. Translated in all 13 locales, with backend and frontend regression tests including the delete-then-dispatch path this began as.
- Picking a spool near the bottom of the label dialog could scroll the dialog itself out of view (#2918, found and fixed by whitigol) — The modal panel was
overflow-hiddenunder a 90vh cap, which makes it a scroll container even though nothing was meant to scroll there. When a checkbox low in the spool list took focus the browser scrolled every scrollable ancestor to bring it into view, and the panel obliged by scrolling its own header and print buttons off the screen. It now clips instead of hiding — the same clipping, but not scrollable — so the only thing that moves is the spool list, which is the part that is supposed to. - A fully transparent spool printed a label with no QR code (#2918, found and fixed by whitigol) — The colour swatch is drawn from the spool's RGBA, and an alpha below opaque puts the PDF into a transparency state that was never turned off again. The QR code is drawn immediately after the swatch, before any other colour is set, so it inherited that alpha: a spool saved at alpha 0 produced a label whose deep-link QR was invisible, and therefore unscannable, while the rest of the label looked correct. The swatch now draws inside a saved graphics state so its transparency ends with it.
- The K value on an AMS slot card went blank after a while, and came back after a backend restart (#2854) — H2-series trays carry no K value of their own; they report a calibration index, and the number on the card is resolved from that against the printer's calibration table. The printer answers that query per nozzle diameter, and it answers whoever asks — BambuStudio's queries arrive on the same topic Bambuddy listens to — but every response was stored as though it were the whole printer's table. The nightly GitHub backup asks for 0.2, 0.4, 0.6 and 0.8 in turn and finishes on 0.8, which holds nothing on a 0.4-plus-0.6 machine, so the table was left empty and every K value on the card disappeared until something happened to refill it. Responses are now filed under the nozzle they describe, so an empty answer for a size the printer does not have clears only that size. The same change fixes the three spool-assignment paths that look a calibration index up by nozzle diameter and had been quietly finding nothing whenever the last response was for a different nozzle.
- A slot could show the other nozzle's K value, and a nozzle that had been swapped out could keep showing its own — Calibration indices are numbered per nozzle, so index 16 exists on each and means something different on each. The card's WebSocket updates keyed on the index alone, so whichever nozzle's table was read last won the slot; only the first render, which comes from a different code path, got it right. Both now resolve a slot through the extruder it feeds, and fall back to which nozzles are actually fitted when that is not enough to single one out. Where neither settles it the card shows nothing rather than a confident wrong number.
- A fresh Bambuddy showed no K values at all until someone opened the Profiles page — Nothing read the calibration table when a printer connected. It arrived by luck: a visit to Profiles or Configure Slot, a GitHub backup, or the printer answering somebody else's query. It is now read once per connection, asking only for the nozzle sizes actually fitted.
- Turning Spoolman mode on deleted every built-in slot assignment, and turning it back off did not restore them (#2812, reported by chitrangdesign) — The toggle ran an unfiltered
delete(SpoolAssignment)across every printer. Switching straight back cleared the other table instead, so the two directions were symmetric in code and one-way in effect, and the setting auto-saves on a 500 ms debounce with no save button and no confirmation. Opening the settings page to see what the option did was enough to destroy the configuration — the reporter's log shows four toggles in 85 seconds and the assignments never came back. The deletion had a real reason: checks that read both assignment tables would otherwise let a row in the mode you are not using answer for the mode you are, which is how #1473 was fixed. That reason has moved to where it belongs: every place that could be confused now asks which mode is active rather than reading whichever table happens to have rows, and nothing is deleted on a toggle. Each mode keeps its own assignments, so switching is reversible and inspecting a mode costs nothing. Six sites needed it, and only two are reads you would guess at. The per-slot K-profile lookup consults the built-in table first and, on a hit with no matching profile, deliberately stops rather than falling through to Spoolman, so a leftover row would have shadowed the Spoolman binding — the symptom #1556 reported from the other direction. Configuring a slot writes its K-profile against whichever table answers first, so the same leftover would have filed a calibration against a spool the printer is not using and never written the local one, leaving a calibration that appeared to succeed and then did not apply. And the auto-unlink pass that drops an assignment whose tray no longer matches its fingerprint ends in a delete: left ungated it would have removed the preserved rows one slot at a time as the AMS contents changed under the other mode, undoing the whole point of this more slowly but just as completely. The remaining two are the missing-assignment notification and the queue cost estimate. A sixth, the built-in remaining-weight fallback inside the Spoolman AMS sync, is deliberately left inert: it was unreachable while the table was being emptied, it is keyed by slot rather than by spool, andcreate_spoolwritesremaining_weightunconditionally, so waking it up would seed a stale figure into a brand new Spoolman spool. Existing installs need no migration: their inactive table is already empty, because it was being emptied. - A print that could not debit a spool said nothing about it (#2812) — This is what turned the toggle above from an annoyance into lost filament. A print whose assignments existed at print start and were gone by the time it finished resolved its 3MF, read its per-filament grams, resolved its tray, and then skipped the debit because the row no longer existed — logged at INFO, invisible under the default log level, with the completion notification firing as usual. 65.49 g was never deducted and the reporter only noticed because a spool's remaining weight looked wrong. The skip is now a warning that names the grams, and a completed print that failed to charge a tray it drew from raises the missing-spool-assignment notification, not only the print-start check that runs before the job and was correct to stay quiet. The two are different statements — the first says the weight may not be tracked, the second says it was not — so a print warned at start may notify twice, which is the right trade. This is independent of the toggle and catches any other cause of an assignment disappearing mid-print.
- Print cost ignored the linked Spoolman spool's price and always used the default rate (#2591, reported by khaosdoctor) — Spoolman holds per-spool pricing, and #261 gave that as the reason for integrating with it, but nothing ever read it. A print's cost is set once, at archive time, from the built-in Filament catalogue matched on the primary type, falling back to a global default rate — and in Spoolman mode nothing revisited that figure afterwards. The per-spool recompute that would have fixed it runs only over rows the built-in inventory writes, and Spoolman mode hands the usage tracker
spoolman_owns_usageat print start so it writes none. On an install with an empty catalogue, which is what the reporter had, every print came out at the default no matter what the spool cost. Multi-material was wrong twice over: the primary type's rate was applied to the whole print's weight, so a slot of expensive PA was billed at the price of the PLA beside it. Each slot is now priced from the spool it was actually charged to, as the charge is made, and the per-slot costs are summed — which is what fixes the multi-material case rather than a separate change. The rate is the spool's ownpricewhen set, else the filament's, divided byfilament.weight; that is the same net-grams field the remain%-delta path already divides by, so a spool that can be charged by percentage can always be priced. A missing or zero price is read as unpriced rather than free, and those grams — along with a tray that has no Spoolman row and any filament the sliced file never attributed — are covered at the default rate in one subtraction against the archive's own total, so a partially priced print still reports a whole-print figure instead of a fraction of one (#1344 in the other inventory mode). Only the first run writes the archive's cost, matching the built-in writer (#1378). Pricing is applied even when the slot-to-tray mapping was a positional guess, unlike the colour and material rewrites beside it: those overwrite what the slicer recorded, whereas the grams have already been deducted from these spools and the archive should say what that deduction was worth. - A rescan or a cost recalculation quietly replaced a Spoolman-derived cost with a default-rate one — Both rebuild an archive's cost from the built-in inventory's usage rows and fall back to the catalogue or the global default when there are none. In Spoolman mode there are never any, so the fallback was not a recalculation but a downgrade, and it would have undone the pricing above on the next rescan. The spool-to-slot resolution a price is derived from only exists while a print is being completed and cannot be rebuilt from the archive row afterwards, so both now leave a cost alone rather than replacing it with a worse one, and the bulk endpoint reports how many it kept. An archive that has no cost yet is still priced, and with Spoolman off both behave exactly as before.
- A print sent from Bambu Studio charged the wrong Spoolman spool, and rewrote the archive to match (#2953, reported by bitelvl1) — A sliced file numbers its filaments 1..4; which AMS tray each came from is decided when the job is sent, and #2768 gave the Spoolman writer two ways to recover that decision when the print did not come through Bambuddy: the printer's own
mappingfield, and a colour match of the 3MF's slots against the loaded trays. An A1 can satisfy neither. It publishes nomappingfield, and it drops the MQTT connection when Bambuddy subscribes to its request topic, so the slicer's instruction never arrives either — which leaves the colour match, and that compares hex strings exactly. The reporter sliced with a generic black profile against a tray they had set to #111111. No match, so every print fell through to the positional default and charged slot 1 to whatever sat in the first tray: 2.17 g onto a grey PLA+ spool, while the print was fed from tray 3. The printer had already said so.Tray change during print: tray=3 at layer=0is in their log, recorded 90 seconds in, and read further down the same completion pass to decide which slots the print had touched — the same pass then charged tray 0 on a guess. A single-slot print is now pinned to the tray the printer reported feeding from, using the ladder the built-in inventory writer has consulted all along: the mid-print tray-change log, then the tray loaded at print start, then the current one, then the last real tray seen. Spoolman users were the only ones not getting it, which is why an install with the internal inventory in use never showed this. Gated on exactly one slot with usage, because a multi-colour print movestray_nowon every change and one reading cannot then be attributed to one slot; and it declines when the log holds more than one switch, so an AMS-backup runout still splits per segment (#1793) rather than landing whole on one spool. Where nothing at all names a tray the positional default still stands — it is right for an AMS loaded in slicer order — but it now says so at warning level, and no longer restamps the archive's filament colour and material from a spool it picked by position. That restamp is what made the fault look like data loss: the grams can be put back, whereas overwriting what the slicer recorded leaves nothing to compare against, and the reporter's archive had already been rewritten from #000000 to the wrong spool's grey. - One unexplained disconnect could stop a printer reporting slicer print mappings for good — Bambuddy subscribes to the printer's request topic to intercept the mapping a slicer sends with a print. Printers th
Changelog truncated — see the full CHANGELOG.md for the complete list.