github maziggy/bambuddy v0.2.5b1-daily.20260518
Daily Beta Build v0.2.5b1-daily.20260518

pre-release3 hours ago

Note

This is a daily beta build (2026-05-18). 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

  • Print labels: sort by colour as an alternative to spool-ID order (#1410, requested by elit3ge) — Reporter asked for an option to order the printed label sheet by colour instead of spool number so a multi-colour roll of Avery sheets / box labels groups related colours together physically. The label-render backend (labels.py) already honoured caller order — both POST /inventory/labels and POST /spoolman/labels preserve the order of spool_ids in the request body and pass it straight to the PDF renderer — so the fix is frontend-only. LabelTemplatePickerModal gains a small sortMode toggle ("By ID" / "By colour") rendered as a chip pair next to the material-filter row. The "by colour" mode converts each spool's rgba to HSL and returns a [bucket, position] sort key: chromatic colours (saturation ≥ 0.1) go in bucket 0 ordered by hue 0..360 so the sheet reads as a continuous rainbow; achromatic colours (greys, blacks, whites, plus missing/invalid rgba) go in bucket 1 ordered by lightness so the neutrals trail the rainbow black → white. Multi-colour spools sort on their primary rgba — the secondary extra_colors stripe still renders on the printed label but doesn't drive the sort, since multi-tone sorting would need a perceptual-distance model the use case doesn't justify. Stable tiebreaker on spool ID keeps identical-colour spools in a deterministic order across renders. The previous [...selectedIds].sort((a, b) => a - b) at submit time was forcing every PDF to ID order regardless of any frontend sorting — that's been replaced with sortedSpools.filter(s => selectedIds.has(s.id)).map(s => s.id) so the visible order flows through to the wire. Session-only state — toggle resets to "By ID" each time the modal opens, no persisted setting (label printing is a rare action and the user picks what they want every time). i18n: 3 new keys (inventory.labels.sortBy.{label, id, color}) translated across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW); parity check holds at 4852 leaves per locale. Tests: two new in LabelTemplatePickerModal.test.tsx — one asserts the "By colour" toggle reorders the submit payload to [Red, Ivory, Blue, Black] (hue 0° / 33° / 240° then neutral with lightness 0) using the existing 4-spool fixture, the other guards the default "By ID" path so adding the toggle didn't quietly regress users who never click it. 17 modal tests green; frontend build clean.
  • Camera: in-app diagnostic for "Connection lost" (#1395 follow-up) — Second step of the camera architecture overhaul. When the camera viewer hits its error state, a new Diagnose button next to Retry runs a staged check against the printer and renders the result inline: which stage failed, how long it took, and a translated remediation hint. Cuts off the "user opens a 'camera broken' ticket → wait days → ask for the support bundle → finally figure out it was their reverse proxy / LAN-only toggle / wrong access code" loop at the user's screen. Backend ships backend/app/services/camera_diagnose.py (orchestrator) and a new POST /printers/{id}/camera/diagnose route in camera.py. Stages: (1) tcp_reachable — opens a TCP socket to the camera port (322 RTSPS / 6000 chamber image) with a 3-second timeout; distinguishes timeout (tcp_timeout → "printer not reachable, check IP/network/power") from refused (tcp_refused → "camera port closed, check LAN-only and developer mode") from host-unreachable (tcp_unreachable → "printer not reachable"). (2) first_frame — captures one JPEG end-to-end via the existing capture_camera_frame_bytes pipeline (15-second timeout, same code that powers /camera/snapshot); auth, RTSP handshake, and first keyframe collapse into one stage because the user-facing answer is the same regardless of which sub-layer failed. Live-stream shortcut: when a viewer is currently watching the printer's camera AND the buffered last-frame timestamp is fresher than 10 seconds, the diagnostic skips the real test and returns live_stream_active_healthy — opening a fresh socket would kick the live viewer off on single-camera-connection firmwares (the #1348 reconnect-storm trigger), so we trust the real-world evidence instead. Response includes structured metadata for support triage: protocol (rtsp / chamber_image), port, profile (default or the model name with an override — currently only P2S), per-stage duration in ms, and the machine-readable summary code. Frontend adds CameraDiagnoseModal.tsx that fires the API call on mount, renders one row per stage with green-check / red-X / grey-skipped icons, and shows the summary remediation message in a bordered banner styled by overall status. The metadata line at the bottom (protocol / port / profile) lets support triage ask "what does your modal say?" instead of "send the support bundle". A Run again button re-runs the diagnostic without dismissing the modal. EmbeddedCameraViewer error state grows the Diagnose button (kept "Retry" as the primary action; Diagnose is the escape hatch for users who can't see what's wrong). A small stethoscope icon also lives in the viewer's always-visible control bar between Refresh and Fullscreen, so pre-flight testing ("did my firmware update break the camera?", "is the camera up before I send a print?") doesn't require waiting for the stream to fail first. Also lifted the previously-hard-coded "Camera unavailable" / "Retry" strings into camera.unavailable / camera.retry so the error UI is properly translated alongside the new keys. i18n: 16 new keys (unavailable, retry, plus diagnose.{button,modalTitle,running,runFailed,retry,stage.*,summary.*,meta.*}) translated across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). German "Diagnose" is a real cognate — added to IDENTICAL_TO_EN_ALLOWED.de rather than translated to a synthetic. Parity check holds at 4849 leaves per locale. Tests: 11 backend unit tests in test_camera_diagnose.py cover the live-stream shortcut (skip when fresh, run when stale), the three TCP failure modes (timeout / refused / OSError) → distinct summary codes, the first-frame stage (no-frame and capture-exception cases), the all-OK path, and the result metadata (P2S → P2S profile / rtsp / 322; A1 → default / chamber_image / 6000; X1C → default / rtsp / 322). 1 backend integration test pins the route's response shape end-to-end. 3 frontend tests in CameraDiagnoseModal.test.tsx (mounted → API call, failure → translated remediation, Run again → re-call). 5021 backend tests + 1905 frontend tests green; ruff clean; build clean; i18n parity clean.

Fixed

  • Inventory: "Total Consumed" now includes archived spools' usage, and the eraser works on archived too (#1390 follow-up, reported by IndividualGhost1905) — After the original #1390 fix shipped, the reporter noticed that archiving a spool with consumed weight quietly subtracted that weight from the "Total Consumed" stat at the top of the Inventory page, and un-archiving put it back. Total Consumed is a running counter (lifetime usage since the last reset), not a current-inventory snapshot, so a spool's recorded prints SHOULD stay in the total even after the user archives the physical roll — otherwise the reset baseline becomes meaningless and the running total walks down as users tidy up their inventory. Root cause was a stats loop in InventoryPage.tsx that gated every aggregate (totalConsumed, totalWeight, lowStock, byMaterial, activeCount) behind a single if (s.archived_at) continue; check. Fix splits the loop so totalConsumed is computed BEFORE the archived-skip and the other aggregates after it, matching the semantic difference between "running counter" and "currently-available inventory". Two adjacent regressions the reporter also surfaced are fixed in the same pass: (a) the per-spool eraser button in the inventory card grid used to require !spool.archived_at && spool.weight_used > 0 — archived spools had no way to zero their tracking counter without first being un-archived. The archived_at half of that gate is gone; the weight_used > 0 half stays. (b) activeSpoolIds, the target list for the "Reset all usage" bulk action, used to filter out archived spools — so a Reset-all click left archived consumption stuck in the (now-corrected) totalConsumed total. Renamed to resetableSpoolIds and broadened to include archived, so a Reset-all genuinely zeroes the stat in one click. Backend reset endpoints already accept archived IDs (both inventory.py::reset_spool_usage and the Spoolman mirror), so this is frontend-only. Inventory-mode parity holds (both modes share InventoryPage). i18n: 8 tooltip/confirm strings retranslated across all 8 locales — the "every active spool" / "all {{count}} active spools" wording was now incorrect (archived included), so each locale's resetAllUsageTooltip drops "active" and resetAllUsageConfirm makes the archived-inclusion explicit ("(archived included)" / "(incluindo as arquivadas)" / "(含已归档)" etc.); parity holds at 4852 leaves. Tests: a new InventoryPageArchivedConsumed.test.tsx with a 2-spool fixture (active 300 g + archived 500 g) pins totalConsumed = 800g after the fix and asserts the "Reset all spool usage" button stays rendered; a future refactor that re-introduces the archived-skip drops the assertion to "300g" and CI fails. 13 InventoryPage tests + i18n parity + build all green.
  • P2S camera: relaxed ffmpeg probe settings so the RTSP stream actually locks (#1395 follow-up, reported by Tschipel) — Reporter on a P2S running firmware 01.02.00.00 saw the camera connect for a few seconds and then time out, repeating. P1S on the same install worked fine because P1S uses the chamber-image protocol (port 6000), not RTSP — different code path. The P2S RTSP path was running ffmpeg with -probesize 32 -analyzeduration 0, tuned for X1/H2 fast startup. The P2S's slower keyframe pacing means ffmpeg can't lock onto the stream within 32 bytes; its own stderr literally says "Stream #0: not enough frames to estimate rate; consider increasing probesize". After ~2 s ffmpeg gives up, Bambuddy reconnects, the cycle repeats. The naïve "just bump probesize" patch would regress every other RTSP-capable printer, so the fix is also the first step of the camera architecture overhaul: per-model tuning lives in a new backend/app/services/camera_profiles.py registry instead of hard-coded module constants. CameraProfile dataclass holds the previously-global knobs (probesize, analyzeduration, rtsp_reconnect_max, rtsp_reconnect_delay, plus an extra_ffmpeg_input_args hook for future per-model flags); get_camera_profile(model) returns the model's profile or the default. The default profile preserves the historical X1/H2 fast-startup values verbatim — X1, X1C, X1E, X2D, H2C, H2D, H2D Pro, H2S all see no behaviour change. P2S gets the only override today: probesize=1_000_000, analyzeduration=500_000 — enough room for the slow keyframe without adding multi-second startup latency. Internal SSDP codes (e.g. N7 → P2S) resolve via an alias map so the camera path works during the early-connect window before the display name is settled. The two _RTSP_MAX_RECONNECTS / _RTSP_RECONNECT_DELAY module constants are gone in favour of profile.rtsp_reconnect_max / profile.rtsp_reconnect_delay; same defaults, but now overridable per model. Pattern is intentionally extensible — adding the next quirky model is a config entry in _PROFILES, not another global constant. Tests: 9 new in test_camera_profiles.py cover unknown model → default, None/empty → default, default preserves historical values, P2S has relaxed probe, P2S internal code (N7) resolves to P2S profile, lookup is case-insensitive, every other RTSP model still uses the default (so the next refactor regression is caught at unit-test time), profile is frozen (immutable). 58 existing camera-related tests still green; 5008 backend tests total green; ruff clean.

Changed

  • Inventory: spool ID surfaced in the edit modal and the AMS filament hover card (#1385, contributed by chanakyan-arivumani in #1402, reported by pgladel) — Reporter asked for the Spoolman / internal spool ID to be visible when editing a spool and when hovering the AMS-loaded filament tile, so the install can be cross-checked against the underlying spool row without opening Spoolman's UI separately. The data was already on the rendered components; only the rendering was missing. SpoolFormModal header now shows #<id> in muted monospace next to the "Edit Spool" title — but only in edit mode; copy and create paths don't surface an ID because no stable ID exists yet (a copy produces a new spool, and surfacing the source spool's ID there would mislead the user into thinking the new spool inherited it). FilamentHoverCard's assigned-spool block shows the same #<id> inline with the brand/material/colour line; the existing <p class="truncate"> is wrapped in a flex container with min-w-0 on the parent and shrink-0 on the new span so the truncation still kicks in on long names and the ID stays at full width. Inventory-mode parity holds without any branching — both internal and Spoolman spools carry an id with the same shape so the modal renders the right ID regardless of which inventory backend is in use. Tests: one regression in FilamentHoverCard.test.tsx (asserts #42 renders in the assigned-spool block) plus three added in SpoolFormModal.test.tsx as post-PR work — edit mode shows the ID, create mode shows none, copy mode shows none. The copy-mode test is the load-bearing case: a future refactor that drops the isEditing && guard would silently start leaking the source spool's ID into the Copy header, and now fails the test instead. 51 affected frontend tests green; frontend build clean.
  • Archives → Print Log: filename column expands to fit available width and wraps long names instead of clipping at 200 px (#1406, requested by daFreeMan) — Reporter on a 27" monitor saw long filenames like Simple_Print_Monitor_-_ST7789_1.54_display_case_ truncated even though the table had plenty of unused horizontal space. The print-name <span> had a hard truncate max-w-[200px] cap that ignored viewport width entirely. Replaced with break-words + a title attribute, dropping the explicit max-width so the column auto-sizes to content. On wide screens the full name shows on a single line; on narrow ones it wraps inside the cell instead of forcing horizontal scroll. The title hover preserves the original tooltip affordance for the rare case where a really long name still gets truncated by viewport constraints. Frontend build clean; 23 ArchivesPage tests still pass.

Fixed

  • Library "Open in Slicer": broken when the display name lacked .3mf or contained / \ ? # (#1413, contributed by benhalverson in #1416, reported by ddingg) — Reporter on Windows 11 / Chrome saw Bambu Studio and OrcaSlicer reject the slicer URL from the 3D-preview modal's "Open in Slicer" button with a parse error, even though downloading the same URL with curl worked. The MakerWorld "Save and open" path and the "Recent imports" entry both worked fine — different code path. Root cause: GET /library/files/{file_id}/dl/{token}/{filename} uses the URL-tail filename purely as a hint for the slicer to detect the file format from the path; the backend itself reads file.filename from the DB (library.py:3806) and ignores the URL segment. When ModelViewerModal.handleOpenInSlicer passed the modal title (display name like "Mecha Mewtwo No AMS Multi Color Parted Statue" — no extension) verbatim through encodeURIComponent, the resulting URL ended without .3mf and the slicer's client-side extension sniff refused to parse the response. The same path also let / \ ? # through, which can survive encodeURIComponent (/ is unencoded by spec) and break the slicer's URL parser separately. Fix adds a small buildSlicerUrlFilename(filename) helper to frontend/src/api/client.ts that strips / \ ? # (replacing them with _) and appends .3mf when missing; getLibrarySlicerDownloadUrl now routes the filename through it before encodeURIComponent. The .3mf check is case-insensitive (safe.toLowerCase().endsWith('.3mf')) so model.3MF is handled correctly — a subtle improvement over the equivalent inline logic that's still present on the archive-side helpers getArchiveForSlicer and getArchiveSlicerDownloadUrl (call-site consolidation is a separate follow-up). Safe because ModelViewerModal.tsx:264 already gates the button to fileType === '3mf' library files, so unconditional .3mf append never produces nonsense like model.gcode.3mf. Two new tests in frontend/src/__tests__/api/client.test.ts cover both branches (display name without extension → .3mf appended; display name with / ? # → replaced with _). 21 client tests green; frontend build clean.
  • Add Spool modal: hex colour field can be typed into character-by-character again (#1407, reported by anthonyma94) — Pre-fix, after typing the first hex character the input's value snapped to e.g. "A00000" (the #1055 fix aggressively padded to 8 chars on every keystroke), the cursor jumped to the end, and the next keystroke landed at position 7 — which the original 7-char-truncation branch then dropped. Net effect: only the first character was ever typed; the rest stayed as zeros unless the user pasted a full hex code. Fix splits "what the user is typing" from "what gets sent to the backend": the input now has its own draft state holding 0–6 chars freely, and updateField('rgba', ...) only fires once the draft reaches a complete 6-char RGB (commits as <6chars>FF). On blur, a partial 1–5 char draft is right-padded with 0 and committed so the form state always carries a valid 8-char rgba — preserves the #1055 invariant that the backend never sees a malformed value, without re-introducing the truncate-on-keystroke trap. A useEffect keeps the draft in sync when an external action (the colour picker, a swatch click, edit-mode load) changes the canonical hex. Paste of 7-/8-char strings truncates to the leading RGB triplet: Bambu filaments are opaque and the UI never exposed an alpha affordance, so dropping the (undocumented) "paste with alpha" case is fine. The existing ColorSectionHexInput.test.tsx was rewritten to match the new contract — 8 tests covering both new behaviours (draft reflects each keystroke, no commit while partial, commits on length 6, blur-padding for partials, no commit when cleared then blurred) and the kept #1055 invariants (committed rgba is always 8 hex chars, 7-/8-char paste truncates, non-hex chars stripped). 51 spool-form frontend tests green; frontend build clean.
  • Virtual Printer queue: timelapse / bed-leveling / flow-cali / vibration-cali / layer-inspect now inherit the slicer's choice instead of always falling back to global defaults (#1403, reported by pwostran) — Reporter sliced in OrcaSlicer with timelapse enabled, sent to a VP queue, started the job from the queue and got no timelapse video. The dispatch chain itself was correct (queue item → scheduler → MQTT command honours timelapse); the gap was at queue-add time: the VP's _add_to_print_queue read default_timelapse from settings (introduced in #1235 to stop column defaults from winning), but ignored the slicer's project_file MQTT command entirely. The slicer's choice — which Bambu Handy / Bambu Studio / Orca all surface in their "Print options" dialog and ship in the MQTT payload as timelapse: true|1 — was being thrown away. So a user with default_timelapse=false (the new-install value) would have to either flip the global setting or manually edit every queue item, even though their slicer's UI was clearly saying "record timelapse for this job". Fix: on_print_command in the VP manager now stashes the slicer's project_file dict keyed by filename, and _add_to_print_queue waits up to 2 s for that capture before reading the settings fallback. Each option flows through per-field — slicer value wins if present, else the existing settings default (so users who explicitly set default_timelapse=true in their VP workflow card still get that on slicers that don't send a print command). MQTT field naming is preserved exactly: bed_leveling (single L) on the wire stays mapped to bed_levelling (double L) on the Bambuddy column. Integer 0/1 from H-family slicers and bool true/false from P1/X1 slicers both coerce correctly via bool(). Wait is skipped when there's no MQTT server attached to the VP instance (covers unit tests calling _add_to_print_queue directly so they don't pay the 2 s tax) and capture is consumed on use so the dict stays bounded across many prints. Two new regressions in test_virtual_printer.py::TestPrintQueueMode: test_add_to_print_queue_inherits_slicer_print_options (slicer=True overrides settings=False across all 5 fields; capture is consumed) and test_add_to_print_queue_coerces_slicer_integer_zero_one (H-family integer payload is coerced). The existing #1235 test (test_add_to_print_queue_uses_workflow_defaults_from_settings) still passes because the no-MQTT-attached gate skips the wait, so the settings fallback path is preserved when no slicer capture exists. Plus two side-bugs surfaced while investigating Martin's "modal not respected" hypothesis — the support-package evidence cleared the reprint modal (46 print_scheduler and 33 background_dispatch events with timelapse: true shipped to real P1S printers, end-to-end working), but the same dig turned up two latent issues worth fixing in the same pass: (a) POST /webhook/printer/{id}/start was broken on four axesawait printer_manager.start_print(...) against a def (not async def) function, queue_item.archive_id (int) passed as the filename arg, printer_manager.get_status(...).get(...) against a PrinterState dataclass (not a dict), and every print option discarded (timelapse, bed_levelling, AMS mapping). The route would 500 before ever reaching the printer. Rewritten to mirror POST /print-queue/{item_id}/start: just clear manual_start=False on the next pending queue item and let the scheduler dispatch it with the queue's stored options intact. Three new regressions in test_webhook_start_print.py (clears manual_start, preserves stored print options, 404 when no pending items / unknown printer). (b) vibration_cali default drift in background_dispatch.pyReprintRequest.vibration_cali and FilePrintRequest.vibration_cali both default to True (matches Bambu Studio behaviour for X1/P1 series), but the two _process_job call sites read job.options.get("vibration_cali", False). Cosmetic today because the frontend always sends the field, but a latent landmine for any future caller that bypasses the schema (e.g. an internal dispatcher seeding options programmatically). Both call sites flipped to True; new contract test test_dispatch_option_defaults_align_with_request_schema_defaults introspects the source to lock the alignment for all six print-option fields so this drift can't recur. 116 VP unit tests green; 4999 backend tests green; ruff clean.
  • Inventory: "Reset usage to 0" no longer inflates remaining weight back to label_weight (#1390 follow-up, reported by IndividualGhost1905) — Reporter reset a 544 g spool's consumed counter and watched its displayed remaining jump to 1000 g — exactly the opposite of what the dialog promised ("Spools and remaining weights are not changed"). Root cause was an architectural conflation in the internal inventory model: a single weight_used column was doing two jobs, the resettable "consumed since tracking started" stat AND the basis for the displayed remaining (label_weight - weight_used). Zeroing it correctly cleared the stat but unavoidably reset remaining to full. Spoolman has separate used_weight and remaining_weight fields, so its API call was correct, but Bambuddy's frontend was also computing remaining as label_weight - weight_used for Spoolman spools (ignoring Spoolman's real remaining_weight field), so the same visual bug bit in Spoolman mode too. Internal mode fix: new weight_used_baseline column (Float, default 0) on the spool table; the "Total Consumed" display is now weight_used - weight_used_baseline clamped to ≥0; the reset endpoints stamp baseline = weight_used and leave weight_used untouched, so remaining (= label_weight - weight_used) is preserved. Subsequent prints continue to grow weight_used and the resettable counter naturally tracks the post-reset delta. Spoolman parity fix: _map_spoolman_spool now reads Spoolman's remaining_weight field and returns a synthetic weight_used = label_weight - remaining_weight so the frontend's remaining calc matches Spoolman's real stored value; weight_used_baseline is computed as synthetic_weight_used - real_used_weight so weight_used - baseline equals Spoolman's used_weight (the resettable counter). After a Spoolman reset (real used_weight=0, real remaining_weight=544) the user sees consumed=0 and remaining=544 — identical to internal mode. Also fixed a related Spoolman bug: editing a spool's metadata after a reset would PATCH Spoolman with remaining_weight = label - used_weight = 1000, overwriting the real 544 g; update_spool now derives the default weight_used from Spoolman's remaining_weight instead of used_weight so non-weight edits preserve the existing physical state. Frontend totalConsumed aggregate in InventoryPage and the three "consumed" displays in ForecastPanel (delta-rate, per-SKU totalUsedG, per-spool consumed cell) all switched to Math.max(0, weight_used - (weight_used_baseline ?? 0)). The ?? 0 fallback keeps pre-migration installs rendering correctly until init_db() runs the idempotent ALTER TABLE spool ADD COLUMN weight_used_baseline REAL DEFAULT 0 (works on both SQLite and Postgres). Tests: test_spool_reset_usage.py rewritten — old asserts that the endpoint zeroed weight_used now assert it stamps baseline = weight_used and leaves weight_used alone, plus a new test_reset_then_print_advances_only_the_counter test that simulates a 50 g print after a reset and confirms consumed=50, remaining=494 (i.e. remaining keeps decrementing across the reset). test_spoolman_inventory_helpers.py gets two new tests on the mapper covering pre-reset and post-reset Spoolman shapes. test_spoolman_inventory_api.py::test_reset_spool_usage updated to assert the new InventorySpool contract (consumed=0, remaining=750, baseline absorbs the reset). 4993 backend tests green; ruff clean; frontend build clean. Per the inventory-parity rule saved last session: both modes now ship the same UX, both call sites verified end-to-end before declaring done.
  • Adding a printer with a wrong access code (or unreachable IP) no longer creates an empty card — Several support reports traced back to a single root cause: the user mistyped their access code in the Add Printer dialog, POST /printers/ happily persisted the row, the subsequent printer_manager.connect_printer() call was fire-and-forget so the failure was invisible, and the dashboard ended up showing a printer card that could never display state. The create route now runs printer_manager.test_connection() (the same MQTT probe the standalone Test Connection button has always used) BEFORE inserting the row, and refuses with HTTP 400 if the probe fails. The Printer row is never written on failure. Structured error response: backend returns {detail: {code: "printer_connection_failed", message: "..."}} rather than a plain English string — the new ApiError.code field on the frontend lets the toast layer pick a localized printers.toast.connectionFailedNotAdded key instead of surfacing the English fallback. Existing tests kept green via an autouse _mock_printer_test_connection fixture in test_printers_api.py that defaults the probe to success; a new test_create_printer_rejects_when_mqtt_probe_fails asserts the failure path returns 400, surfaces the stable code, AND verifies the row was not persisted (the critical part — earlier versions of the regression would have passed even if we'd left the row behind). 8 new i18n translations for printers.toast.connectionFailedNotAdded across all 8 locales; parity holds at 4831 leaves. 28 printer-route tests green.

Changed

  • GitHub backup: save-failure messages render inline on the card instead of as a toast — The new "repository is not private" rejection message is ~250 chars listing every credential the backup carries, which clips badly in a toast. Both the initial-setup save and the debounced autosave now stash the backend's error message into a new saveError state and render it as a red inline banner above the test-result block, with whitespace-pre-wrap so the full message stays readable. The banner clears on a successful save, on the next save attempt, and as soon as the user starts editing the URL / token / provider (the three fields whose changes invalidate the privacy check) — so it doesn't linger after the user has already addressed the cause. Short success toasts ("Settings saved", "Token updated", "Backup enabled") are unchanged. Manual dismiss button included for users who want to clear it without retrying.

Security

  • GitHub backup refuses to save against a non-private repository — While auditing real-world Bambuddy backup repos on GitHub I found several that were left public by their owners. That's a serious data leak: the settings backup only filtered bambu_cloud_token and auth_secret_key, so mqtt_username, mqtt_password, ha_token, prometheus_token, bambu_cloud_email, external_url, and the printer access codes (via K-profiles, which carry the serial number) were going to whatever visibility the user picked when they created the repo. Fix is a hard guard at every save and re-checked on every push: POST /github-backup/config and PATCH /github-backup/config (when the URL, token, or provider changes) run a connection test internally and return HTTP 400 unless is_private comes back True. Same check fires inside run_backup() before every scheduled or manual push, so a repository that was private at config time but later flipped to public in the provider's UI gets a clear "Backup aborted: the target repository is no longer private" failure entry instead of leaking the next backup. Implementation: each provider's test_connection (GitHubBackend, ForgejoBackend override, GitLabBackend override; GiteaBackend inherits unchanged) now returns is_private: bool | NoneTrue for confirmed private, False for public (or GitLab's internal), None for "couldn't determine" (older self-hosted APIs, non-2xx responses). The route helper _enforce_private_repo rejects anything that isn't True, with separate error messages for the public case ("Make the repository private...") vs the unknown-visibility case ("...could not confirm..."). Frontend test-connection UI now renders the visibility result inline — green check + "Repository is private — safe to back up to" when confirmed, red banner with the full list of credentials at risk + "Saving is blocked until..." when public, yellow banner + "could not determine" when null. Three new i18n keys (repoIsPrivate, repoIsPublicWarning, repoVisibilityUnknown) translated across all 8 locales; parity holds at 4830 leaves. Wiki docs/features/backup.md gains a top-level !!! danger "Private repositories only" block listing what's at stake and what to do if the user already has a public backup repo, plus every per-provider setup step is updated from "(can be private)" to "(must be private)". Tests: 5 new in test_github_backup_api.py::TestGitHubBackupPrivateRepoGuard — create rejects public (400 + "not private" in detail), create rejects unknown visibility (400 + "could not confirm"), create rejects failed test_connection (400 + propagates the underlying message), PATCH that changes the URL re-runs the check and rejects on public, PATCH that touches an unrelated field (e.g. schedule_enabled) does NOT call test_connection (proven via a mock that raises if called — without the field-change gate, every benign toggle would trigger a live API call). The existing 15 tests now use an autouse fixture that mocks test_connection to return private-success so they don't try to reach github.com. 4905 backend tests green.

Fixed

  • Spoolman edit-spool: editing a spool no longer mints duplicate filaments in the Spoolman catalogue (#1357 follow-up, reported by pgladel) — After the initial #1357 close, the reporter showed that BB was still spawning new Spoolman filament rows on every subsequent edit. The previous fix taught find_or_create_filament to bridge the AMS-sync name shape ("Glow") with the user-edit shape ("PLA Glow"), but only on the find path — the moment the user changed any field that fed the match key (subtype/material/brand/color_hex) the lookup missed and a brand-new filament was created, the spool was re-linked to it, and the previous filament was orphaned. Repeating the loop produced the spread the reporter screenshotted (IDs 126/127/128/129 all "Amazon Basics / PLA Glow / PLA", slight color variants). Root fix is a behaviour change in PATCH /spoolman/inventory/spools/{id}: before calling find_or_create_filament, the route now computes whether the desired metadata still matches the current linked filament and, if so, skips the lookup entirely (a no-op metadata edit — just note or weight_used — never touches the filament catalogue). When metadata IS changing it consults a new SpoolmanClient.is_filament_shared(filament_id, exclude_spool_id) helper: if the current filament is a singleton (only this spool points at it, archived spools included so a sibling-archive doesn't fake singleton-ness), the route PATCHes that filament in place via patch_filamentname, material, color_hex, weight, plus a vendor_id resolved via find_or_create_vendor when the brand changed. Only when the filament is genuinely shared with another spool does the route fall back to the legacy find_or_create_filament path, because PATCHing a shared filament would silently rewrite every sibling spool's metadata. Net effect mirrors internal-inventory behaviour ([[feedback_inventory_modes_parity]] saved this session): editing a spool updates the thing the spool already points at, instead of proliferating new entities. Three new tests in test_spoolman_inventory_api.py::TestSpoolmanInventoryCRUD cover the new contract: a no-op metadata edit (only note/weight_used) does NOT call find_or_create_filament OR patch_filament; a subtype change against a singleton filament calls patch_filament(7, {...name: "PLA Matte"}) and NOT find_or_create_filament; the same change with is_filament_shared mocked to True falls back to find_or_create_filament and does NOT call patch_filament. 162 spoolman-inventory tests + 192 broader spoolman tests green; ruff clean.
  • Inventory: "Print labels…" now works in Spoolman mode — Both endpoints already exist (POST /inventory/labels for the built-in table, POST /spoolman/labels for Spoolman), and the LabelTemplatePickerModal correctly branches on a spoolmanMode prop. But the modal was instantiated in InventoryPage.tsx with spoolmanMode={false} hard-coded, with a stale comment from the original PR claiming "Spoolman path hands users an iframe straight to Spoolman so the per-spool button never shows in that context". That assumption stopped being true when the unified inventory UI shipped — the per-spool button DOES show in Spoolman mode now, but every click resolved to /inventory/labels with Spoolman spool IDs and returned 404 Spool(s) not found. Fix passes the actual spoolmanMode value through to the modal (one-line change, plus removing the stale comment block). The existing LabelTemplatePickerModal.test.tsx already covers both branches at the component level — the gap was that no test exercised the InventoryPage wiring. This is another instance of the parity rule from [#1390 follow-up]: inventory features must ship the same UX in both modes; per the new feedback memory, any future inventory change gets a mental checklist of both routes + both client methods + both UI gates before being considered shipped.

Added

  • Inventory: "Reset usage to 0" also works in Spoolman mode (#1390 follow-up) — The first cut of this action only wired the built-in inventory path, so Spoolman users saw the eraser icon disappear when they switched modes. Now the same two endpoints exist on the Spoolman inventory router: POST /spoolman/inventory/spools/{spool_id}/reset-usage PATCHes Spoolman's /spool/{id} with used_weight: 0 for a single spool, POST /spoolman/inventory/spools/reset-usage-bulk does the same per ID across an explicit list and returns {reset: N} (individual Spoolman failures are logged and counted out, the batch keeps going). A reset_spool_usage(spool_id) helper on SpoolmanClient is the actual HTTP call. The mutations in InventoryPage.tsx already had the right shape — they now switch on spoolmanMode to pick api.resetSpoolmanInventorySpoolUsage / api.bulkResetSpoolmanInventorySpoolUsage vs the internal-inventory client methods, and the three spoolmanMode ? undefined : ... gates that hid the eraser buttons in Spoolman mode are gone. Three new tests in test_spoolman_inventory_api.py lock the Spoolman path (per-spool, bulk, and the typo-wipe guard on empty list). The wiki page now says "Spoolman users get the same actions" instead of the original "Spoolman-mode users don't see either button" note. 4900 backend tests green.
  • Inventory: "Reset usage to 0" per spool and across all active spools (#1390 follow-up, requested by IndividualGhost1905) — Each spool's weight_used counter accumulates over its lifetime and feeds the "Total Consumed (Since tracking started)" stat on the Inventory page. There was no way to clear it without nuking the spool or manually editing the field — and manually setting weight_used=0 via PATCH /spools/{id} auto-locks the spool (weight_locked=true is auto-set whenever weight_used is sent explicitly, so AMS auto-sync stops touching the spool), which is the wrong behaviour for "clean-slate my Total Consumed stat so future prints track from zero". Two dedicated endpoints in backend/app/api/routes/inventory.py zero the counter without touching the lock flag: POST /inventory/spools/{spool_id}/reset-usage (single spool) returns the updated SpoolResponse; POST /inventory/spools/reset-usage-bulk ({spool_ids: [int, ...]}) returns {reset: N}. The bulk endpoint rejects empty / missing spool_ids (HTTP 400) — no wildcard / "reset-all" shortcut, since a typo there would wipe the entire inventory's tracking; the caller must explicitly pass the list. Both leave weight_locked alone: if the user had locked the spool, the lock stays; if it was unlocked, it stays unlocked and the next AMS sync picks up from zero. Frontend adds two affordances: a small eraser icon button on the "Total Consumed" stat card (visible only when there's actually usage to reset AND we're not in Spoolman mode) that opens a confirm modal explaining what the reset clears and that the spools / remaining weights are not changed, and an eraser icon in each table row's action column (visible only on active spools with weight_used > 0, hidden in Spoolman mode since Spoolman manages its own usage accounting). Both routes share the same ConfirmModal infrastructure as delete/archive — confirmAction state now covers 'delete' | 'archive' | 'reset-usage' | 'reset-all-usage'. i18n: 10 new keys (resetUsage, resetUsageTooltip, resetUsageConfirm, resetAllUsage, resetAllUsageTooltip, resetAllUsageConfirm, usageReset, allUsageReset, resetUsageFailed, plus resetUsage reused as confirm button label) translated across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). Parity check holds at 4827 leaves per locale. Tests: 8 new regressions in test_spool_reset_usage.py cover per-spool reset zeroes weight_used, per-spool reset does NOT auto-lock, per-spool reset preserves an existing lock, 404 for missing spool, bulk reset zeroes only listed spools (untouched spools keep their usage — the typo-wipe guard), bulk reset rejects empty list (400), bulk reset rejects missing spool_ids field (400), bulk reset preserves weight_locked across mixed locked/unlocked targets. 4897 backend + 1901 frontend tests green.

Changed

  • Settings → Filament: "Spool Catalog" now shows the same UI in Spoolman mode as in internal-inventory mode — Previously, switching to Spoolman mode hijacked the Spool Catalog card and replaced it with a Spoolman filament list (Vendor — Name / Material / Weight / Spool Weight) with inline edit for name + spool_weight. Two separate concepts had been merged into one card: a Bambuddy-local spool tare catalog (the actual purpose of the card — name + weight definitions used to compute spool tare) vs a filament editor for Spoolman's Filament entity. The filament-editor view replaced the spool tare table entirely in Spoolman mode, with no way to see or manage the spool catalog. Now the card always renders the local Spool Catalog (Add / Edit / Delete / Export / Import / Reset / bulk-delete) regardless of inventory mode. The Spoolman-filament inline editor is removed — Spoolman users edit filament name / spool_weight in Spoolman's own UI. Side effect of the rewrite: the noisy GET /api/v1/spoolman/inventory/filaments → 400 Bad Request that fired on the Filament settings page even when Spoolman is disabled is gone, because the component no longer issues the probe at all. Files affected: frontend/src/components/SpoolCatalogSettings.tsx (rewrite, ~750 → ~445 lines), frontend/src/components/SpoolWeightUpdateModal.tsx (deleted — only used by the removed editor), test file rewritten to match the simplified component. No backend changes — PATCH /spoolman/inventory/filaments/{id} route still exists for API consumers, just no longer wired to a UI.

Fixed

  • Stats page widgets now match Quick Stats — every panel reads per-event data (#1390 follow-up, reported by IndividualGhost1905) — After #1378 moved Quick Stats and the run aggregates to print_log_entries, six widgets (Filament Used, Filament Cost, Filament Trends, Printer Stats By Weight / Time, By Material, Color Distribution) plus Failure Analysis still iterated the archive list. Two divergences fell out of that split. Reprints: each reprint of an archive adds a new print_log_entries row but the print_archives row gets overwritten in place, so event-based widgets counted N reprints while archive-based widgets counted 1. Hard-deleted archives: the foreign key is ON DELETE SET NULL, so the event survives as an orphan (archive_id=NULL) — Quick Stats kept counting it, archive-iterating widgets couldn't see it. The reporter's test server (14 archives / 52 events / 29 orphans confirmed by the diagnostic query) made the split very visible. Fix swaps the data source in two places: (1) GET /archives/slim (the only frontend caller is StatsPage, so every widget that consumes the archives query gets the per-event data in one step) now reads from PrintLogEntry, LEFT JOINs PrintArchive for the sliced print_time_seconds estimate (null for orphans, and downstream widgets already fall back to actual_time_seconds / duration_seconds), uses PrintLogEntry.duration_seconds as the authoritative measured-time field when present (the original computed-from-started/completed_at path is kept as the fallback so legacy event rows from pre-#1378 still surface time), and returns quantity=1 per event since per-event semantics make the archive-level quantity multiplier meaningless (verified no StatsPage widget actually reads quantitygrep -n "\\.quantity" frontend/src/pages/StatsPage.tsx returns nothing); (2) FailureAnalysisService switched from PrintArchive to PrintLogEntry for every aggregation (totals, by reason, by filament, by printer, by hour, recent failures, weekly trend) — project_id filtering still resolves through the archive table (events don't carry a direct project link) but counts the matching events, not the archives. The conftest archive_factory already synthesizes a matching PrintLogEntry per archive (added when #1378 landed), so existing tests stay green; one small tweak there now syncs the synthesized event's created_at with the archive's so date-range filtered tests don't lose the event to server_default=func.now(). Three new regressions in test_archives_api.py: test_slim_counts_reprints_as_separate_rows (three reprints → three slim rows → 3× filament summed correctly), test_slim_includes_orphan_events (archive deleted, event survives, slim still returns it with print_time_seconds=null), test_failure_analysis_counts_reprints_and_orphans (a reprint of a failed archive + an orphan failed event both contribute to failed_prints and failures_by_reason). One existing assertion updated — the test_slim_returns_only_expected_fields test was asserting quantity == 2 from an archive_factory(..., quantity=2) call, which no longer rounds-trips through the per-event endpoint; updated to quantity == 1 with a comment pointing at the semantic shift. 4889 backend tests green, 31 StatsPage frontend tests green, ruff clean.
  • FTP upload no longer silently treats 426 "Failure reading network stream" as success (#1401, second root cause reported by iitazz) — Looking at the support bundle from iitazz showed every FTP upload to their P2S (firmware 01.02.00.00) ending the same way: data channel sendall completes in ~200 ms at an impossibly high "speed" (7+ MB/s for files the printer can only actually receive at ~1–2 MB/s), then voidresp returns 426 Failure reading network stream. (error_temp) from the printer, and Bambuddy proceeds — WARNING FTP STOR confirmation not received for X (proceeding): 426 ... followed immediately by INFO FTP upload complete. The print command then gets dispatched, the printer tries to parse what's actually a partial 3MF (the reporter's downloaded-from-printer 3MF was 458752 bytes — exactly 7 × 65536, our FTP chunk size — for a 668025-byte source), and surfaces the "unable to parse 3mf file" error the reporter sees. Two stacked failures: a P2S firmware / TLS-data-channel quirk that severs the FTP data stream mid-transfer (separate investigation; #1401 doesn't fix that), AND the voidresp handler in backend/app/services/bambu_ftp.py swallowing the resulting 426 because the original comment assumed "the data was fully sent so the file is likely on the SD card" — true for socket-level timeouts where we just didn't HEAR the 226 in time (H2D needs 30+ s tolerance and we want to keep that), false for 426 where the printer is explicitly telling us the data stream itself was cut. Fix splits the broad except Exception into two branches: except ftplib.Error (covers error_reply, error_temp, error_perm, error_proto — the server responded with a failure on the control channel) logs at ERROR and re-raises, so the outer except (OSError, ftplib.Error) returns False and the dispatcher sees a real upload failure instead of green-lighting a print of a truncated file; except Exception keeps the existing proceed-with-warning behaviour for socket timeouts so the H2D 30-second voidresp tolerance survives. Same split applied to upload_bytes() since it had the same except Exception: pass shape. The reporter will still hit the underlying 426 (we haven't fixed the P2S transport problem yet — that's separate), but they'll now see an upload failure surfaced honestly rather than a confusing parse error 30 seconds into the print attempt. Tests: two new regressions in TestUpload patch _ftp.voidresp to raise ftplib.error_temp("426 ...") and assert both upload_file() and upload_bytes() return False. 18 upload-related tests green. The earlier-this-section validation fix is unrelated and stays — it still catches genuinely raw .gcode files at the upload step.
  • Upload validation rejects unprintable 3MF / raw-gcode files at the upload step instead of letting them fail at the printer (#1401, reported by iitazz) — Reporter sliced in OrcaSlicer, uploaded the result to Bambuddy, clicked Print, and the printer rejected with "Printing stopped because the printer was unable to parse the 3mf file" — every time, for multiple files, on both library uploads and SD-card-browsed files. Trace through the support bundle showed: (a) the stored library file ended in .gcode (not .gcode.3mf), and (b) background_dispatch.py constructs the FTP destination filename by appending .3mf when the source doesn't already end in .gcode.3mf / .3mf — so raw gcode gets shipped to the printer named whatever.gcode.3mf and the firmware's 3MF parser chokes on the missing zip header. The same shape also manifests as Failed to parse plates from archive ... File is not a zip file warnings on Bambuddy's side. Whether the user manually re-extensioned a file or their slicer saved as .gcode instead of .gcode.3mf, the right place to catch this is the upload, not the printer 30 seconds later. New validate_print_file_upload() helper in backend/app/api/routes/library.py runs two checks: (1) reject any filename ending in .gcode (but not .gcode.3mf) with a clear message — "Raw .gcode files can't be printed on Bambu printers in network mode — they need a .gcode.3mf zip container (gcode plus metadata). Re-export from your slicer and make sure the file ends in '.gcode.3mf', not just '.gcode'. If your OS hides extensions, double-check the file with the extension visible." (2) For any filename ending in .3mf (incl. the compound .gcode.3mf), verify the file body starts with PK\x03\x04 (ZIP magic bytes); reject otherwise with a message pointing at the slicer's "Export Plate Sliced File" action. Suffix-based check rather than os.path.splitext because compound extensions like .gcode.3mf show up as just .3mf after splitext — both must trigger the same validation. Applied to every relevant upload route: POST /library/files (covers File Manager upload AND the printer-card drag-drop, which routes through the same endpoint), POST /archives/upload (single archive), POST /archives/upload-bulk (rejects bad files per-row instead of aborting the batch — one bad file in a 10-file drag-drop doesn't lose the other nine), POST /archives/{archive_id}/source (per-archive source 3MF), POST /archives/upload-source (slicer-post-processing match-by-name). Validation runs AFTER _resolve_upload_destination so folder-permission rejections (403 readonly, 400 missing-path, 409 collision) still take precedence — preserves existing error ordering. STL / image / other non-print uploads bypass the validator entirely; Bambuddy is also a library, not just a print dispatcher. Frontend visibility fix in FileUploadModal.tsx (same component used by File Manager + Printers page + Archives): the modal auto-closed after setIsUploading(false) regardless of per-file results, so a 400 rejection from the new validator was technically captured but never shown — the modal vanished too quickly. Now (a) errors render inline as red text under the file row instead of as a hover-only title tooltip, and (b) the modal stays open if any file ended with status='error', so the user can read the backend's actual remediation message before clicking Close. The bulk archive UploadModal.tsx was already showing inline errors and not auto-closing — that one didn't need the fix. Tests: 7 new integration tests in TestPrintFileUploadValidation cover: raw .gcode rejection at the library route (asserts the error message names the remedy), non-zip .3mf rejection, non-zip .gcode.3mf rejection (compound-extension code path), happy-path valid .gcode.3mf accepted, STL / non-print extensions still bypass, POST /archives/upload non-zip rejection, POST /archives/upload-bulk per-file error collection with mixed good/bad files in one request. Plus one fixture update in test_external_folders_api.pytest_upload_persists_correct_db_shape was uploading model.3mf with placeholder bytes b"x" to exercise the DB-shape path; updated to use a minimal real zip so the new validator doesn't block the unrelated test. 4968 backend tests green, 41 FileUploadModal frontend tests green, ruff + frontend build clean.

Added

  • Inventory: Storage Location filter chip (#1400, reported by pgladel) — Reporter manages a lot of physical filament storage locations and wanted a quick way to narrow the inventory list to "what's in shelf A" / "what's in drawer 1" without typing a search query each time. Inventory page grows a new filter chip alongside the existing Material / Brand / Category / Spool Name dropdowns. Distinct storage-location values are pulled from the spool list and rendered as options; selecting one filters the table to spools assigned to that location. An additional No location set entry appears when at least one spool has an empty storage_location, so users can find unfiled spools the same way categoryNone works for unfiled categories. The chip self-hides when no spool has a storage location set (avoids noise on fresh installs). Pattern is identical to the existing Category chip from #729 — clear-all-filters and hasActiveFilters both include the new state. Whitespace normalisation: distinct-value extraction and filter comparison both .trim() the field so a spool whose location was saved as "Shelf A " doesn't render as a separate dropdown option from "Shelf A". i18n: reuses the existing inventory.storageLocation label (already shipped for the spool-edit field — no duplication); adds a new inventory.storageLocationNone key, translated to all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). The "Extended Solution" from the issue (dashboard widget showing locations) is not in this change — open to revisiting if there's appetite. Parity check holds at 4818 leaves per locale. 24 InventoryPage tests in the existing suite still pass.
  • Smart plugs: auto-off after AMS drying completes (#1349, reported by Kyobinoyo) — Reporter asked for the equivalent of the existing print-finish auto-off, but triggered when an AMS drying cycle ends — so the smart plug that powers the printer + AMS combo cuts power once humidity has been driven out, without the user babysitting it. Shipped as a simple per-plug pair of fields that mirrors the existing print-finish auto-off shape. Per-AMS plug routing (separate plug for the AMS only, per-AMS targeting on dual-AMS printers) was scoped out for now — Bambuddy's plug model is plug→printer, not plug→AMS, so the trigger fires whenever any AMS attached to the linked printer finishes a dry cycle. Two new SmartPlug columns with a same-migration block in database.py (SQLite uses BOOLEAN DEFAULT 0 / INTEGER DEFAULT 10; Postgres branches to DEFAULT false / IF NOT EXISTS): auto_off_after_drying BOOLEAN (defaults False so nobody opts in by accident); off_delay_after_drying_minutes INTEGER (defaults 10 — separate from the print-finish delay because the AMS chamber is hot post-cycle and users often want longer cooldown than the print-finish default of 5). Trigger is observed at the MQTT layer, not the scheduler — BambuMQTTClient now keeps a per-AMS _previous_dry_times: dict[int, int] and, every time _handle_ams_data finalises the merged AMS list, walks each unit looking for the dry_time > 0 → 0 falling edge. When it fires, the new on_drying_complete(ams_id) callback runs, plumbed through PrinterManager.set_drying_complete_callback exactly the way on_print_start / on_print_complete already are. The seed-from-zero false positive (first MQTT push reports dry_time=0 and the previous would otherwise read as 0→0) is guarded by the explicit previous > 0 check, and the per-AMS state means dual-AMS printers can finish drying on AMS 0 and AMS 1 independently without the second one missing the edge. Observing the falling edge at the MQTT layer (rather than in print_scheduler._sync_drying_state) is deliberate: the scheduler's _drying_in_progress dict only tracks auto-drying initiated by the scheduler itself, so manually-triggered drying from the printer card would not fire there. The new path catches queue-triggered, ambient, AND manual drying identically because it observes firmware-reported state, not our own intent. Manager hook in SmartPlugManager.on_drying_complete(printer_id, db) mirrors on_print_complete but reads the drying-specific toggle, calls _schedule_delayed_off with off_delay_after_drying_minutes (always time-based — temperature-cooldown is meaningful for the printer hotend, not the AMS chamber, and Bambuddy doesn't track AMS chamber temperature). The HA-script guard from the print-finish path is preserved (scripts can be triggered but not turned off, so they're skipped). Frontend adds a single toggle + delay input on the Smart Plug card next to the existing "Auto Off" section: "Auto Off After Drying" and "Drying delay (minutes)". No changes to the Add Smart Plug modal beyond what the new fields require. Backend tests in test_smart_plug_manager.py cover the new shape: drying auto-off schedules with the correct per-plug delay; the toggle being off is a no-op even when auto_off (print-finish) is on; the master enabled flag still gates; HA script entities are skipped; printer with no linked plugs is a silent no-op. test_bambu_mqtt.py gets a new TestDryingCompleteCallback class covering the falling-edge firing once, the seed-from-zero non-fire guard, repeated zero-pushes after the edge not refiring, per-AMS independent tracking on dual-AMS units, and the "new cycle after completion refires" case (covers the user starting a second dry from the printer card). 4961 backend tests green; SQLite + Postgres 16 migration verified idempotent. i18n: 3 new keys (autoOffAfterDrying, autoOffAfterDryingDescription, delayAfterDryingMinutes) translated across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). Parity check holds at 4817 leaves per locale.

Changed

  • Bulk and scheduled archive purge now honour the soft / hard delete choice that single-archive delete already exposes (#1390 follow-up) — Reporter IndividualGhost1905 followed up after the #1378 / #1343 backfill fix landed and pointed out the next inconsistency: the per-archive delete dialog has had a "Also remove from Quick Stats" checkbox since #1343, but the bulk "Purge Old" button and the scheduled daily auto-purge sweeper both ignored that choice and hard-deleted unconditionally. The "Purge Old" path called archive_purge_service.purge_older_than which routed through ArchiveService.delete_archive directly — dropped the archive row, the linked PrintLogEntry rows got ON DELETE SET NULL so they survived with archive_id=NULL, Quick Stats kept the filament / cost / energy contribution from the orphaned log rows but the archive-list-iterating widgets (Filament Trends / Printer Stats / By Material / Color Distribution) lost the contribution and Time Accuracy lost the join target. Visibly inconsistent, and "automatically deleted from statistics without any warning" was a fair characterisation of the half that did drop. Fix is to thread the same purge_stats parameter through every surface, defaulting to soft-delete (matches the single-archive default — files off disk, archive row hidden via deleted_at, Quick Stats fully preserved, all archive-list widgets keep showing the row). Three surfaces affected: (1) POST /archives/purge accepts purge_stats in the body, defaults False (soft); the response now echoes which mode ran. (2) GET /archives/purge/preview accepts the same flag as a query param so the count matches what a real purge would touch — soft mode excludes already-soft-deleted rows, hard mode counts them as eligible-for-promotion. (3) The auto-purge archive_auto_purge_stats setting (default False) controls whether the daily sweeper runs in soft or hard mode; the existing _maybe_run_auto_purge reads it on every tick. ArchivePurgeRequest / ArchivePurgeSettings schemas extended, archive_purge_service.purge_older_than and preview_purge take purge_stats=False kwarg, the existing single-row delete tests pass unchanged. Frontend: "Purge old archives" modal grew a checkbox below the preview ("Also remove from statistics" with a hint explaining the difference), and the Settings → Archives auto-purge card grew the matching toggle (disabled when auto-purge itself is off). Copy in the modal rewritten across all 8 locales to reflect that the default no longer "permanently removes from the database" but instead hides + removes files while keeping Quick Stats intact. Behaviour change for existing auto-purge users: the sweeper used to hard-delete by default and now soft-deletes by default. After the upgrade, existing auto-purge users will start preserving more data in Quick Stats rather than losing it — the safer direction of the two, but call it out. Users who want the old hard-delete behaviour can tick the new toggle once. 4 new integration tests in test_archive_purge_api.py pin the new contract: manual purge soft-deletes by default, manual purge hard-deletes when purge_stats=true body flag is set, auto-purge soft-deletes by default, auto-purge hard-deletes when the settings opt-in. Existing throttle/disabled tests still pass. 11 tests total in the file, all green; 4951 in the full backend suite. i18n parity check clean across all 8 locales.
  • Cloud login: corrected the access-token hint to reflect that Bambu Lab no longer surfaces the token in any UI, and called out the China-region constraint explicitly (#1396) — Reporter wintsa123 filed that China-region users can't log into Bambuddy. The code path itself is fine: PR #1013 (April) already added the China-region selector to the login form and routes token validation to api.bambulab.cn. The actual gap was documentation. The old in-app accessTokenHint said "Paste your Bambu Lab access token (from Bambu Studio)" — but Bambu Studio never exposed the token in any UI, and the profile page on bambulab.com that used to show it is gone. For China-region accounts the email/password flow is fundamentally unusable because those accounts are bound to phone numbers, not email — token login is the only path, and the hint didn't say so. Updated accessTokenHint in all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW) to state that China accounts must use this path and point at the wiki for the MakerWorld-cookie retrieval procedure. Wiki page features/cloud-profiles.md also rewritten under "Access Token Login": adds a "Region: China must use token login" note, replaces the dead "from Bambu Studio" guidance with the working MakerWorld-cookie method (browser DevTools → Application → Cookies → token), keeps the Python-script alternative for global-region accounts, and flags that the cookie value is sensitive. No backend changes — the token-validation endpoint accepts both global and china regions and routes to the right API host already.

Fixed

  • Virtual Printer (queue / immediate / review modes): AMS data flickered or disappeared in BambuStudio between pushalls on P1S/A1 targets (#1387) — Reporter vmhomelab ran a Print Queue VP against a P1S, opened BambuStudio, and saw the External Spool only — no AMS. Toggling Auto-Dispatch (which triggers a VP restart) made AMS briefly appear, then it reverted to defaults. Proxy Mode worked fine. The earlier #1371 sticky-keys fix only handled one of two Bambu firmware incremental-push shapes: it preserved cached AMS when the incoming push omitted the ams key entirely (H2D's common incremental shape). The reporter's P1S firmware (01.09.01.00) instead sends incrementals with the ams key present but the inner ams.ams array stripped — {ams_status: 1, humidity: 2} instead of {ams: [...], ams_status: 1}. To the previous sticky-keys check that read as "key present, leave new state alone," so the bridge cache got overwritten with the stripped blob; the slicer's next 1 Hz read saw ams with no unit list and fell back to the "no AMS" default render. Toggling Auto-Dispatch restarted the VP and got a fresh pushall in; the next P1S incremental stripped it again. (H2D rarely hits this — its incrementals typically don't carry ams at all, so #1371 alone was enough there. The reporter's same-VP-architecture pinging both an H2D and a P1S would observe the H2D works while the P1S doesn't, which is exactly the split that surfaced this.) Fix is a deep-merge applied to the ams key inside the bridge cache, mirroring the structure Bambuddy itself already does in bambu_mqtt.py::_handle_ams_data (which is why Bambuddy's own AMS display stays coherent on the same firmware): scalar fields like ams_status and humidity take the new value, but the ams.ams array is merged unit-by-unit on id, each unit's tray array is merged tray-by-tray on id, and units / trays the incremental doesn't mention survive intact from the cached full state. A tray-targeted incremental during a print like {ams: [{id: 0, tray: [{id: 0, state: 11}]}]} now updates that one tray's state without nuking the other three trays' tray_type/tray_color. Helper added as _merge_ams_dict in backend/app/services/virtual_printer/mqtt_bridge.py next to _ip_to_uint32_le, called from the existing sticky-keys block. Three new regression tests under TestPushStatusCache in backend/tests/unit/test_vp_mqtt_bridge.py cover the status-only partial (the reporter's exact reproduction), the multi-AMS unit-level merge, and the multi-tray merge. The existing test_incoming_ams_update_replaces_cached_ams still passes — fresh full updates still take effect, the merge only protects the cache from stripped incrementals. 32 tests total in that file, all green. Verified the cross-subnet topology from the report (printer / Bambuddy / slicer each on a different /24) is incidental: the symptom is the same regardless of subnet once the partial-shape arrives; the latency just makes the "empty cache when slicer first connects" race more visible. ProxyMode is unaffected because Proxy is raw byte-forwarding rather than a cached-as-base mirror — it never had this class of bug.
  • Quick Stats showed Filament Cost = 0 and empty Time Accuracy on pre-upgrade data after the 0.2.4.1 stats rewrite (#1390) — Reporter IndividualGhost1905 upgraded to 0.2.4.1 (which shipped the per-event aggregation rewrite from #1378) and saw the Stats page split between consistent values (Total Prints / Print Time / Filament Used / Energy / Success Rate matched the archive list) and zero-or-empty ones (Filament Cost, Time Accuracy). Inconsistency was a migration gap: #1378 added six columns to print_log_entriesarchive_id, cost, energy_kwh, energy_cost, failure_reason, created_by_id — but didn't backfill any of them. So every pre-upgrade log entry kept NULL on all six. The new Quick Stats query sums PrintLogEntry.cost (gets 0 for legacy data); the time-accuracy query joins PrintArchive ON archive_id (drops every legacy run from the average). Counts and per-row fields that already existed pre-#1378 (status, duration_seconds, filament_used_grams) kept working — which is why some panels looked right and others didn't. Fix is a two-step backfill in run_migrations next to the existing column-add block (DML, runs inside begin_nested() not _safe_execute since the latter is documented "DDL only"): step 1 links each orphan log entry to its archive via print_name + printer_id (highest archive id wins on tiebreak — newest matches the overwrite-then-stop shape that pre-#1378 reprints left behind); step 2 copies archive.cost / energy_kwh / energy_cost onto the latest matching log entry per archive, but only for archives where no log entry yet carries a cost. That second clause is the idempotency anchor and also the double-count guard for users running this migration after #1378 has already written cost-bearing rows for new runs — those archives are left untouched. Earlier reprints stay NULL, matching the "first/latest writes, rest stay NULL" convention #1378 introduced. Sum across the legacy reprint chain reproduces sum-of-archive-cost exactly, so the Quick Stats Filament Cost column matches the pre-upgrade total instead of dropping to zero. SQL is plain ANSI — correlated UPDATE with LIMIT 1 in the SET subquery, WHERE id IN (SELECT MAX(id) ... GROUP BY archive_id HAVING SUM(CASE WHEN cost IS NOT NULL THEN 1 ELSE 0 END) = 0) — verified end-to-end on both SQLite (4 unit tests in test_print_log_backfill_migration.py) and postgres:16-alpine + asyncpg (live container reproduction). For the other widgets the reporter listed (Printer Stats, Filament Trends, By Material, Success by Material, Color Distribution) — those still iterate the archives list on the frontend rather than calling /stats, so they read consistent pre-upgrade data and aren't part of this fix; the inconsistency the reporter saw between Quick Stats and those widgets resolves itself once the backfill brings Quick Stats in line.
  • Spoolman: spool "Color Name" edits silently never saved — Bambuddy was writing to a field Spoolman doesn't have (#1357) — Reporter pgladel edited a spool's Color Name in Spoolman mode, hit Save, and saw the value snap back to the subtype on the next read. Martin shipped #1319 in May to handle "form round-trips the synth value back as if it were user input" — that fix's read/form-prefill half was correct (the color_name_is_synthesized flag, the blank-on-synth form init), but the write half assumed Spoolman has a color_name field on Filament. It doesn't. Verified against the live FilamentUpdateParameters schema on Spoolman 0.23.1: name, vendor_id, material, price, density, diameter, weight, spool_weight, article_number, comment, settings_extruder_temp, settings_bed_temp, color_hex, multi_color_hexes, multi_color_direction, external_id, extra — that's the lot. No color_name. Spoolman's PATCH happily returns 200 for {"color_name": "Red"} and just silently discards the unknown key. So find_or_create_filament was either patching a void or creating filament after filament with the same field-that-doesn't-stick (which is what produced the reporter's "BB also created a bunch of new filaments" trail of duplicates on each save attempt). The fix takes the same route as the existing BambuStudio slicer-preset storage: persist color_name on spool.extra.bambu_color_name as a JSON-encoded string, register the extra field via ensure_extra_field before write (Spoolman 400s on unknown extra keys), and read it back in _map_spoolman_spool with priority spool.extra.bambu_color_name → filament.color_name (forward-compat for any future Spoolman release that adds it) → subtype synth. Also dropped the now-dead color_name passing through find_or_create_filament and create_filament — Spoolman would discard it anyway and keeping the dead pipe risked the same confusion the next time someone reads this code. The previous "match by name then patch color_name" loop is gone; what survives is the name-match resilience added earlier this turn so an AMS-sync-created filament named "Glow" still matches the user-driven edit's composed "PLA Glow", which prevents the duplicate-filament trail. The frontend form's color_name_is_synthesized handling is unchanged — that part already worked. Tests rewritten across the three affected suites (test_spoolman_inventory_methods.py, test_spoolman_inventory_helpers.py, test_spoolman_inventory_api.py) to pin the new contract: filament patch never carries color_name, route writes to bambu_color_name extra, read prefers extra over filament-field over synth. Verified end-to-end against the live Spoolman instance at the reporter's setup (PATCH /filament with color_name → field absent from response; PATCH /spool with extra.bambu_color_name → field present in response).
  • Add Smart Plug (HA mode) — search dropdown let users pick entities the schema would reject, surfacing as a cryptic regex error on Save (#1388) — Reporter MartinNYHC opened the Add Smart Plug dialog, typed a search prefix matching a multi-entity HA device (a Shelly-style outlet exposing one switch.* and several sensor.* / binary_sensor.* siblings under the same friendly-name prefix), clicked one of the entities, filled in the optional power/energy sensors, and clicked Save. The backend returned 422 with the raw Pydantic message String should match pattern '^(switch|light|input_boolean|script)\.[a-z0-9_]+$'. After the dropdown closed and the search cleared, the entity-list refetch (with no search param) returned the default-domain-filtered list — which didn't include the user's pick — so selectedEntity = haEntities.find(...) was undefined, the field rendered as visually empty (placeholder shown), but haEntityId still held the bad value the user had selected. Root cause was at backend/app/services/homeassistant.py::list_entities: when a search query was present, the function bypassed the domain filter entirely and returned matches across every HA domain — including ones the SmartPlugBase.ha_entity_id regex at backend/app/schemas/smart_plug.py:17 could never accept. Offering a clickable choice the user can't save is broken UX; the fact that the error message then said switch|light|input_boolean|script made it look like a schema problem rather than a search-permissiveness problem. Fix: the allowed-domains filter ({"switch", "light", "input_boolean", "script"}, kept in sync with the schema regex) now always runs, and search composes on top of it as an additional substring match against entity_id or friendly_name. Whitespace-only search strings are treated as no search. Verified the smart-plug code path is unchanged between 0.2.4 and 0.2.4.1 — this bug was latent since the script-domain commit in February 2026 and was only noticed now because the reporter hadn't reopened the modal in months. 5 new regression tests in backend/tests/unit/services/test_homeassistant_list_entities.py cover the no-search baseline, the search-still-domain-filters case (the actual #1388 reproduction), the entity_id-or-friendly_name substring match, case-insensitivity, and the whitespace-only edge case.
  • H2S with no AMS could not start a print — firmware rejected the dispatch with 07FF_8012 "Failed to get AMS mapping table" (#1386) — Reporter krootstijn (H2S + no AMS) clicked Print and got an immediate firmware error. Two stacked misclassifications had quietly added H2S to the dual-nozzle code paths over time. The first was in start_print_job at backend/app/services/bambu_mqtt.py:3168 — the is_h2d flag was set true for ("H2D", "H2D PRO", "H2DPRO", "H2C", "H2S", "X2D"). That single flag controlled both the firmware bool→int format (legitimately needed for the whole H-family) and the external-spool routing branch (ext_ams_id = tray_id if is_h2d else 255) which is only correct for actual dual-nozzle printers. With no AMS, the external-spool sentinel is 254; the dual-nozzle branch wrote ams_id=254 into ams_mapping2 instead of the canonical 255. The exact failure shape (07FF_8012) is even called out in the comment six lines above the bad line — H2S was getting routed straight into the path the comment warned against. The second misclassification was the use_ams=False fallback at bambu_mqtt.py:3213 (if ams_mapping and use_ams and not is_h2d) — meant to skip the safety drop on dual-nozzle printers where use_ams controls nozzle routing — also skipped H2S, so the firmware never got a chance to fall back to external-spool mode. A third site at bambu_mqtt.py:3987 (and its sibling at backend/app/api/routes/kprofiles.py:119) classified dual-nozzle by serial prefix ("094", "20P9", "31B8B"), which is wrong because H2S shares prefix 094 with H2D. Fix splits the conflated flag into two: is_h_family (firmware-format gate, includes H2S) and is_dual_nozzle (routing/use_ams gate, excludes H2S; prefers the runtime _is_dual_nozzle flag set from device.extruder.info and falls back to model name for the brief window right after connect). The K-profile delete and the edit route now use the same two-source check instead of the serial prefix. Empirically verified across 9+ stored H2S support bundles (nozzle_count: 1 in every one) and the reporter's bug log (07FF_8012 immediately after dispatch). Four new regression tests: test_h2s_single_external_spool_uses_main_id, test_h2s_no_ams_forces_use_ams_false, test_h2s_keeps_integer_format_for_calibration_fields, plus a new test_h2s_uses_single_nozzle_format in the K-profile suite. The K-profile detection tests were also updated to set both model name and runtime flag rather than relying on serial prefix, since the source-of-truth has shifted.

Don't miss a new bambuddy release

NewReleases is sending notifications on new releases.