Note
This is a daily beta build (2026-05-09). 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://containrrr.dev/watchtower/) to automatically update when new daily builds are pushed.
Added
- Spool labels: new 40×30 mm template, hex colour code, bolder brand line (#809 follow-up, requested by oliboehm) — Three small enhancements to the spool-label printer rolled into one change. (1) New
box_40x30template — 40×30 mm single label, common DK/Brother roll size. Added to_SINGLE_LABEL_SIZES_MMinbackend/app/services/label_renderer.pyand to the request body'sLiteral[...]enum inbackend/app/api/routes/labels.py; height is ≥ 20 mm so it routes through the existing roomy layout (swatch + QR + full text column). (2) Colour hex code on every label — new_hex_code_label()helper formatsdata.rgbaas#RRGGBB(alpha-stripped, uppercased to match the inventory UI's colour-picker convention) and returns""for missing/malformed input so the caller skips drawing instead of throwing. Rendered as a small line under the material/subtype line in the roomy layout, and as a third line above the spool ID in the tight (AMS) layout — useful when several near-identical material/colour spools sit next to each other in the AMS or on a shelf. (3) Brand line bigger + bold — the brand on every label now renders inHelvetica-Boldinstead ofHelveticaregular, with size bumped 5.5pt → 6.5pt on the tight layout and 7pt → 8pt on the roomy layout, so it's the most legible non-ID field at arm's length. Wiring:SpoolLabelTemplateunion infrontend/src/api/client.tsextended with'box_40x30';LabelTemplatePickerModalgets a newTEMPLATE_OPTIONSentry for it;inventory.labels.templates.box40x30.{label,hint}keys added across all 8 locales (en + de fully translated, fr/it/ja/pt-BR/zh-CN/zh-TW translated to native, with the existing per-key fallback in the modal as a safety net). The 5-template grid still wraps to 2 columns on small viewports per #1230's fix; modal regression test was widened from4to5template buttons. Tests:ALL_TEMPLATESparametrize tuple intest_label_renderer.pyextended withbox_40x30so all 7 generic invariants (PDF header, empty-input, multi-colour, missing-fields, malformed-rgba, long strings, sheet pagination) cover the new template; newtest_hex_color_code_rendered_when_rgba_set(asserts#F5E6D3appears in the uncompressed PDF for both 40×30 and 62×29),test_hex_color_code_skipped_when_rgba_invalid(regex pin: no#RRGGBBshape on the label when rgba is malformed, except the spool ID's#42), andtest_brand_rendered_in_bold_per_809_followup(assertsHelvetica-Boldfont reference is in the PDF — caught a regression if the brand line ever reverts to regular weight). All 33 backend tests + 15 frontend modal tests pass; ruff clean. - Copy spool — duplicate any spool's settings into a fresh inventory row in two clicks (#1234, PR #1246 by MiguelAngelLV) — Adds a copy button (
Copyicon) next to the existing edit button on every spool in the inventory page across all three views (table row, card, grouped table inner row). Clicking it opens the existingSpoolFormModalpre-filled with every field from the source spool — material, brand, color, slicer preset, label/core/cost, K-profiles, all of it — exceptweight_usedwhich is reset to 0 (since the new spool starts full) and the RFID identity fields (tag_uid,tray_uuid,tag_type,data_origin) which aren't part of the form payload anyway, so the new spool is its own physical roll. Save callsapi.createSpool(orapi.createSpoolmanInventorySpoolin Spoolman mode — both inherit the dispatch routing for free). Closes the long-running gap where users with many near-identical spools (e.g. five 1 kg PETG-CF rolls bought in a single order) had to re-enter every field from scratch on each one. Implementation shape:SpoolFormModalProps.mode: 'create' | 'edit' | 'copy'(exported asSpoolFormMode) replaces the previousisEditing = !!spoolheuristic — every existing call site inInventoryPage.tsxwas updated to pass the explicit mode, and the modal's title / submit-button label / weight-reset gate / submit-route branching all key onmodedirectly. TheonCopycallback is optional onSpoolCard,SpoolTableRow, andSpoolTableGroup(matches the existingonPrintLabel?pattern), so the button is conditionally rendered and other consumers of those subcomponents don't get a copy affordance forced on them. Card-view and table-row buttons stop click propagation so clicking copy doesn't also fire the parent row's edit handler. Quick Add interaction: the Quick Add toggle is gatedmode === 'create'(was!isEditing), so it stays out of copy mode — otherwise a user could enable Quick Add and bump quantity to N under the singular "Copy Spool" title and silently bulk-create N copies viabulkCreateMutation. i18n: newinventory.copySpoolkey across all 8 locales (en + de translated, fr/it/ja/pt-BR/zh-CN/zh-TW seeded with English fallback per project flow). Tests: 3 new inSpoolFormModal.test.tsx(SpoolFormModal copy modedescribe block — title shows "Copy Spool", save callscreateSpoolnotupdateSpool,weight_usedreset to 0 in the create payload when copying a spool with non-zero usage), 2 new inInventoryPageCopyButton.test.tsx(table-row copy button click → "Copy Spool" heading, cards-view copy button click → same heading after switching view modes) — guards against the three call sites drifting apart. ExistingSpoolFormBulk.test.tsxandSpoolFormModal.test.tsxrenders that omitted themodeprop were updated with the explicitmode="create"so the tightened Quick Add gate doesn't hide the toggle from them. BothInventoryPageCopyButton.test.tsxandInventoryPageDeepLink.test.tsxgained MSW handlers for the modal's open-time fetches (/api/v1/cloud/status,/api/v1/cloud/local-presets,/api/v1/cloud/builtin-filaments,/api/v1/inventory/color-catalog,/api/v1/inventory/spool-catalog,/api/v1/printers/) — without them MSW passes through to the real network, ECONNREFUSEs, and the rejected fetch resolves after the test environment is torn down, surfacing as a flaky "window is not defined" unhandled rejection in the modal'ssetLoadingCloudPresets(false)finally block (pre-existing flake hit ~1 in 3 full-suite runs at PR head).
Fixed
- GCode Viewer had no in-app way to navigate back — the only exit was the browser's back button — Opening the GCode Viewer from a File Manager card or an Archive card calls
navigate('/gcode-viewer?archive=…' | '?library_file=…'), which mountsGCodeViewerPageas a full-height iframe inside the Layout shell. The page rendered nothing but the iframe, so once the third-party viewer's UI took over the content area there was no in-app affordance to return to the originating list — only the browser's back button. Reported by maziggy. Fix: added a thin back bar above the iframe infrontend/src/pages/GCodeViewerPage.tsxwith anArrowLefticon button. The button label adapts to the entry point —Back to Print Archiveswhen the URL carries?archive=,Back to File Managerwhen it carries?library_file=, genericBackotherwise (covers the rare deep-link / shared-URL case). Click prefersnavigate(-1)so the user lands back in their original list with scroll position and filters preserved; falls back to/archivesor/fileswhen the page was opened in a fresh tab and there's no SPA history to return to. Iframe height is nowflex: 1inside a flex column under the bar instead of a hard-codedcalc(100vh - 3.5rem)— the layout's existing fixed-header offset is unchanged, only the back bar (~36 px) is subtracted from the viewer's vertical real estate. i18n: newgcodeViewer.{back,backToArchives,backToFiles}namespace added to all 8 locales (en + de fully translated, fr/it/ja/pt-BR/zh-CN/zh-TW translated to native using each locale's existing page-title vocabulary —Druckarchiv/Dateimanager,Archives d'impression/Gestionnaire de fichiers,Archivi di stampa/Gestore file,印刷アーカイブ/ファイル管理,Arquivos de impressão/Gerenciador de arquivos,打印归档/文件管理器,列印歸檔/檔案管理器). - Archives card's "Reprint" / "Schedule" / "Slice" button labels truncated to "Re..." / "Sc..." on narrow browser windows (#1249) — The action row on each archive card has six buttons: two labelled (Reprint + Schedule, or Slice when the file isn't sliced yet) plus four icon-only utilities (open in slicer, external link, globe, download, trash). The labelled buttons used
flex-1to share whatever space remained after the four fixed-width icon buttons, with the label rendered as<span className="hidden sm:inline truncate">...</span>— i.e. visible at any viewport ≥ 640px, withtruncateellipsizing when there isn't room. The Tailwind viewport breakpoint can't see the card width. The page's grid grows column count alongside viewport (md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4), so cards stay roughly 320–380 px wide across breakpoints and the leftover ~30 px in each labelled button isn't enough for "Reprint", which lands on screen as "Re..." — repro'd from a small browser window in the reporter's case. Fix: breakpoint bumped fromhidden sm:inline→hidden xl:inlineon all three labelled buttons (Reprint at line 1106, Schedule at line 1117, Slice at line 1153 offrontend/src/pages/ArchivesPage.tsx). Labels now appear only at viewport ≥ 1280px where the cards (3-4 columns of ~320 px) actually have headroom for them; on narrow windows the buttons render icon-only with their existingtitle=tooltip kept intact for hover and assistive-tech disclosure. Trade-off accepted: a wide-viewport-with-wide-sidebar setup that compresses the card to under ~320px will still see the truncation, but that's a corner case — the common "small browser window" path is fixed without restructuring the row. - Spool form's "Slicer Preset" dropdown silently dropped Local Profiles when Bambu Cloud was connected, and collapsed per-printer/per-nozzle variants of cloud and local presets into a single entry (#1248, reported by andretietz) — Two distinct defects in the same code path. Defect 1 (the reported bug):
buildFilamentOptionsinfrontend/src/components/spool-form/utils.tswas precedence-based —if (cloudPresets.length > 0)returned the cloud list and never reached the local-presets branch, so any Local Profile imported via Profiles → Local Profiles was silently invisible whenever the user was logged into Bambu Cloud (the same profile rendered fine with a greenLocalbadge in the AMS Slot configuration modal). The wiki documents the dropdown as "merged and deduplicated" across cloud + local + built-in. Defect 2 (surfaced during fix verification): the spool form was collapsing allBambu Lab P1S 0.4 nozzle/Bambu Lab X1C 0.4 nozzle/Bambu Lab A1 0.4 nozzlevariants of "Bambu PLA Basic" into a single dropdown entry by stripping theprintersuffix and dedup'ing by base name (one Map.set per family for cloud defaults, one per family for local presets). The AMS Slot modal lists each variant individually and filters by the active printer model, so the user observed strictly more entries in the AMS Slot than in the Add Spool modal even after the merge fix. The right semantic for the spool form — printer-agnostic by design, since a spool isn't bound to a printer — is to show every variant as its own row, exactly as if you'd summed the AMS Slot's per-printer-filtered output across all printers. Fix: rewrotebuildFilamentOptionsto (a) actually merge all three sources, dropping the precedence early-return, and (b) push each cloudsetting_idand eachLocalPresetrow as its ownFilamentOptioninstead of collapsing byname.replace(/.*$/, '').displayNamenow keeps the fullprinter 0.4 nozzlesuffix so users can pick the right variant. Built-in dedup against cloud setting_id is preserved (mirrorsConfigureAmsSlotModal.tsx:498exactly). Wiredapi.getBuiltinFilaments()into both callers —SpoolFormModalandSpoolBuddyWriteTagPage. Persistence safety: the savedslicer_filamentshape is unchanged — cloud picks still persist theirsetting_id, local picks still persistpreset.filament_type || String(preset.id)(consumed bybackend/app/utils/filament_ids.py::normalize_slicer_filamentwhich expectsGFL05/GFSL05shapes; persisting the bare LocalPreset row id would break slicing). Local-presetallCodesnow carries both thefilament_typeform and theString(preset.id)form sofindPresetOptionresolves both old (pre-fix) and new picks. React-key collision: with collapse removed, two LocalPreset rows can share the samecodeif they sharefilament_type; the dropdown key inFilamentSection.tsxis now composed${option.code}::${option.name}to stay unique. Tests: newfrontend/src/__tests__/components/spool-form/buildFilamentOptions.test.tswith 9 cases — the #1248 regression case, "one entry per cloud setting_id, no printer collapse", "list each local preset individually", "printer suffix preserved in displayName", localallCodescarrying both shapes, theGFA00↔GFSA00built-in dedup, the all-empty fallback, and the alphabetical sort. The two existingvi.mock('../../api/client')blocks inSpoolFormModal.test.tsxandSpoolFormBulk.test.tsxwere updated with the newgetBuiltinFilamentsstub. - SpoolBuddy install.sh re-run failed with
Permission deniedon root-owned files in update mode —download_spoolbuddy()rangit fetch + git checkout + git reset --hardbefore the post-install chown at the end of the function. If a previous install left stray root-owned files in the tree (e.g.static/assets/*written by an earliersudorun, or a frontend build that wrote as root), thegit reset --hardstep aborted with EACCES on the unlink/replace step before reaching the chown. The script then exited and the kiosk's underlying ownership problem persisted, so the next attempt would fail the same way. Fix: pre-emptivelychown -R spoolbuddy:spoolbuddy "$INSTALL_PATH"in the update branch before any git operation runs. The script already runs as root (enforced bycheck_root), so the chown is always safe. The existing post-install chown at the end stays — it now mostly catches new files created during this run that need their ownership normalised. Same root cause showed up on the kiosk's runtime SSH update path (Bambuddy → kiosk:git checkout dev && git reset --hard origin/devrunning as thespoolbuddyuser) but that path can'tchownwithout sudoers expansion — the install.sh fix is the immediate recovery, and re-running the install script restores a clean ownership baseline that the runtime updater can keep healthy thereafter. - SpoolBuddy SSH update aborted with
TypeError: startswith first arg must be bytes or a tuple of bytes, not strafter the host-key store succeeded —perform_ssh_updatecallsasyncssh.import_known_hosts(...)to materialise anSSHKnownHostsobject for_run_ssh_command'sknown_hosts=keyword arg. Both call sites (the stored-key path at line 221 and the just-stored TOFU re-parse at line 272) passedf"{ip} {key}\n".encode()— i.e.bytes. asyncssh's parser does line-based string operations (line.startswith('#')with astrliteral), so anybytesinput crashes inside its loader withTypeError. The twotry/exceptclauses caught only(ValueError, asyncssh.Error), missingTypeError, so the crash bubbled up and aborted the whole update right after the schema fix successfully persisted the host key. Fix: drop the.encode()at both call sites — pass the str directly. Widened both except clauses to(ValueError, TypeError, asyncssh.Error)so any future asyncssh API surprise degrades to the existing fallback (TOFU mode without host-key verification, with a logger.warning) instead of crashing the update. Existing SSH tests all mockedasyncssh.import_known_hostsitself so they never reached the parser — addedtest_perform_ssh_update_passes_str_not_bytes_to_import_known_hoststo capture both call sites' arguments and assertisinstance(arg, str)so re-introducing.encode()fails CI immediately. - SpoolBuddy SSH update crashed on Postgres with
value too long for type character varying(500)when storing the device's RSA host key —spoolbuddy_devices.ssh_host_keywas declared asString(500), which is fine for SQLite (ignores VARCHAR length) and for ed25519 host keys (~120 chars), but RSA host keys in OpenSSH format are typically 370 chars (2048-bit) → 544 chars (3072-bit) → ~720 chars (4096-bit). Postgres enforces the limit strictly, so any kiosk reporting an RSA-3072 or larger host key on the first SSH update aborted at theUPDATE spoolbuddy_devices SET ssh_host_key=...flush — thegit fetch + pip install + systemctl restartmay have run successfully but the persistence of the TOFU host key failed and the device's update_status was never written. Fix: widenedssh_host_keyfromString(500)→Texton the model, plus an idempotentALTER TABLE spoolbuddy_devices ALTER COLUMN ssh_host_key TYPE TEXTmigration gated onnot is_sqlite()(Postgres-only; SQLite is a no-op since it doesn't enforce VARCHAR length). Existing rows are preserved —TYPE TEXTis a metadata-only change on Postgres forVARCHAR(N)→TEXTso it's a fast migration even on populated tables. Originally introduced in the H1 SSH-host-key TOFU security fix; the 500-char floor was a guess based on ed25519 sizes that the RSA case immediately blew past. - SpoolBuddy kiosk Settings → Update button returned "API keys cannot be used for administrative operations" — Same root cause as the four QuickMenu System buttons fixed in 0.2.4b3 (Restart Daemon / Restart Browser / Reboot / Shutdown), missed in that audit. The
POST /spoolbuddy/devices/{id}/updateroute (kiosk's own Settings → Update Daemon button → SSH update on the kiosk device) was gated onPermission.SETTINGS_UPDATE, butSETTINGS_UPDATEis on the API-key deny-list (_APIKEY_DENIED_PERMISSIONSinbackend/app/core/auth.py, introduced in PR #1241). Every kiosk-side request to update the daemon — regardless of the API key's scope set (Read / Print Queue / Control / Legacy) — tripped the deny-list and returned a hard 403 with that message. The 0.2.4b3 fix explicitly carved /update out with the reasoning "replaces the daemon binary, different threat surface" — but that reasoning was wrong:restart_daemonalready replaces the running daemon process, so daemon-replacement is not a step up in blast radius. The SSH update is also strictly scoped to the single device the operator physically controls (git fetch + pip install + systemctl restarton that one host) — same threat profile as the system commands already running onINVENTORY_UPDATE. Fix: lower/spoolbuddy/devices/{id}/updatefromPermission.SETTINGS_UPDATE→Permission.INVENTORY_UPDATE, matching the rest of the kiosk-scoped routes (calibration/tare,display,cancel-write,system/command,system/command-result,update-status). The main Bambuddy in-app updater atPOST /api/v1/updates/applykeepsSETTINGS_UPDATE— that one operates on the Bambuddy host and is correctly fenced behind the deny-list. Tests:test_trigger_update_requires_settings_update(which pinned the broken behavior — 403 on inventory-only key) is renamed totest_trigger_update_accepts_inventory_updateand now asserts the inventory-only key reaches the device-state check (409 offline) instead of 403, so a future re-tightening of the gate surfaces immediately. Class-level docstring intest_settings_api_key_scrubbing.pyupdated to reflect the corrected threat-model reasoning. - Printer file download 500'd on non-ASCII filenames; same crash latent in three sibling endpoints (#1245, reported by 1000Delta) —
GET /api/v1/printers/{id}/files/download?path=...raisedUnicodeEncodeError: 'latin-1' codec can't encode characters in position …for any path whose filename carried non-ASCII characters (Chinese, Japanese, Arabic, accented Latin), reproducible against P2S firmware on macOS but not target-specific. Cause: the route shovedfilenamestraight intoContent-Disposition: attachment; filename="{filename}"— Starlette/uvicorn encodes response headers as latin-1, so anything outside U+0000..U+00FF crashed at write-time. Same pattern existed in three sibling endpoints reachable with user-controlled non-ASCII input:GET /archives/{id}/qr(usesarchive.print_namefrom 3MF metadata, often non-ASCII),GET /projects/{id}/export(usesproject.name— the existing sanitiser atprojects.py:1648usesc.isalnum()which passes non-ASCII Unicode through, so the crash propagated), and_stream_pdfinlabels.py(latent — current callers pass ASCII-only template names, but the same shape would crash if a future caller passed user input). Fix: new helperbackend/app/utils/http.py::build_content_disposition(filename, disposition="attachment")returns an RFC 6266-compliant header with both an ASCII-stripped legacyfilename="..."fallback and an RFC 5987filename*=UTF-8''<percent-encoded>parameter — every modern browser (Chrome / Firefox / Safari / Edge) prefers the*=form when present, so the original filename round-trips intact through Save-As; the ASCII fallback covers IE10-era clients. Helper wired in at all four call sites in one PR (per project rule: no deferred follow-ups). Tests: 20 unit tests intest_http_utils.pypinning ASCII-fallback rules across plain ASCII / Chinese / Japanese / Arabic / French diacritics /.gcode.3mfdouble-extension / quote-injection / backslash-injection / empty-string and___.zipedge cases, asserting the helper's output round-trips through latin-1 (the crash condition) for every test input. 6 new integration tests intest_printers_api.py::TestPrintersAPI::test_download_printer_file_non_ascii_filenameparametrized over the same character classes (the original龙泡泡石墩子_p2s_ok.gcode.3mfcase from #1245 is included) — each asserts the route returns 200 with an unmangled body, the ASCII fallback in the header matches expectations, andunquote(filename*=)round-trips back to the original Unicode filename. Thanks to 1000Delta for the diagnosis and the proof-of-concept patch onprinters.py— the broader audit (three sibling endpoints, helper extraction, latin-1 round-trip assertions) was done on top of that.