From 597762685c39a324c58cab59aa6ea83981f6d7e1 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sat, 30 May 2026 13:34:10 +0200 Subject: [PATCH] fix(virtual-printer): #1558 Send pre-flight + slicer-surface audit bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1558: cached-as-base push_status only forced gcode_state=IDLE while letting the real printer's live-progress fields (mc_percent, stg_cur, layer_num, ...) leak through. Bambu Studio's Send pre-flight read them as busy and refused. The cached branch now overrides the activity-field set the same way it already overrode storage indicators (#1228) and protocol fields. Same bundle ships a multi-round VP audit that found adjacent bugs in the same family: - #1558: cached branch zeroes mc_print_stage / mc_percent / mc_remaining_time / stg / stg_cur / layer_num / total_layer_num / print_error - MQTT auth: per-IP rate-limit (5/60s lockout), hmac.compare_digest, access_code redacted in DEBUG log - FTP cmd_STOR streams chunks to disk + 4 GiB cap (was buffering whole upload) - Sticky-keys allowlist extended with upgrade_state / xcam / hw_switch_state / nozzle_diameter / nozzle_type / online / ams_status - _pending_files cleanup in finally for archive / queue / dispatch handlers - _add_to_print_queue position uses MAX+1 (was hardcoded 1) - DELETE VP removes orphan PendingUpload rows + upload_dir from disk - Per-VP cert regenerates on shared-CA rotation (real signature verification, not DN match) - DHCP target-IP refresh + queue_force_color_match toggle now restart proxy VPs - Per-slicer bridge-response routing (multi-slicer cross-leak fix via sequence_id map) - Child-service readiness barrier (FTP / MQTT / Bind / SSDP) — no false is_running before sockets bind - H2D Pro O1E / O2D model codes added (experimental, needs field confirmation) - FTP passive port range widened 50000-51000; docker-compose + wiki updated - VP refresh_loop crash now unbinds raw_message_handler; tailscale catches asyncio.TimeoutError; SlicerProxyManager lifecycle hardening --- CHANGELOG.md | 158 +++++++++-- backend/app/api/routes/virtual_printers.py | 34 ++- .../services/virtual_printer/bind_server.py | 16 +- .../services/virtual_printer/certificate.py | 62 ++++- .../services/virtual_printer/diagnostic.py | 17 +- .../services/virtual_printer/ftp_server.py | 143 +++++++--- .../app/services/virtual_printer/manager.py | 187 +++++++++++-- .../services/virtual_printer/mqtt_bridge.py | 28 +- .../services/virtual_printer/mqtt_server.py | 178 ++++++++++++- .../services/virtual_printer/ssdp_server.py | 5 + .../app/services/virtual_printer/tailscale.py | 9 +- .../app/services/virtual_printer/tcp_proxy.py | 68 ++++- .../unit/services/test_virtual_printer.py | 246 +++++++++++++++++- .../unit/test_vp_certificate_rotation.py | 76 ++++++ backend/tests/unit/test_vp_delete_cleanup.py | 137 ++++++++++ backend/tests/unit/test_vp_ftp_stor.py | 143 ++++++++++ backend/tests/unit/test_vp_mqtt_bridge.py | 42 +++ backend/tests/unit/test_vp_mqtt_server.py | 159 +++++++++++ docker-compose.yml | 2 +- install/docker-install.ps1 | 2 +- 20 files changed, 1612 insertions(+), 100 deletions(-) create mode 100644 backend/tests/unit/test_vp_certificate_rotation.py create mode 100644 backend/tests/unit/test_vp_delete_cleanup.py create mode 100644 backend/tests/unit/test_vp_ftp_stor.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a64a86d0..9796ee1b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,27 +6,27 @@ All notable changes to Bambuddy will be documented in this file. ### Added - **System theme detection — sidebar toggle and Settings selector follow OS dark/light preference (#1418, contributed by @TempleClause via PR #1501)** — `ThemeMode` gains a third value `'system'` alongside the existing `'dark'` / `'light'`. The provider listens to `window.matchMedia('(prefers-color-scheme: dark)')`, tracks the OS preference in real time, and exposes a new `resolvedMode: 'light' | 'dark'` to consumers — the actual rendered theme after resolving system → OS preference. Layout's sidebar toggle now cycles `dark → light → system → dark` with the icon hinting at the next stop (`Sun`→`Monitor`→`Moon`); the existing logo selection and the dark/light "active" panel highlight in Settings switched from `mode` to `resolvedMode` so they always reflect what's actually painted, regardless of whether the user chose explicitly or inherited from the OS. Settings → Appearance gained a 3-button Dark / Light / System selector (border-green-keys-off-`mode` so System actually highlights System even when it resolves to dark), with a "Settings saved" toast on click matching the adjacent Background/Accent/Style selects. Existing users' persisted `theme-mode` is untouched — anyone on `dark` or `light` stays there and simply gains an extra stop in the cycle; new installs default to `dark`. **Review-caught fixes shipped in the same PR**: (a) the project's `__tests__/setup.ts` mocked `window.matchMedia` with `vi.fn().mockImplementation(...)`, which `vi.restoreAllMocks()` in three test files reset to "return undefined" — pre-PR nothing called `matchMedia` at render time so the wipe went unnoticed, this PR was the first caller and broke 23 existing tests. Rewritten as a plain function (`Object.defineProperty(window, 'matchMedia', { writable: true, value: (query) => ({...}) })`) so `restoreAllMocks` can't touch it. (b) `themeToggleHint` had previously only been updated in `en.ts`; real translations now ship in all 8 non-English locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW) describing the 3-state cycle without referencing the old sun/moon icon pair. (c) PR description reworded to honestly call out the sidebar cycle change as a behaviour change for every user of the toggle (`dark → light → system` now intercepts where users previously got `dark → light → dark`), with the persisted-preference-unchanged caveat made explicit. (d) New i18n key `nav.switchToSystem` with real translations across all 9 locales (`'Switch to system mode'` / `'Zum Systemmodus wechseln'` / `'システムモードに切替'` etc.). **Tests**: 11 new in `ThemeContext.test.tsx` (systemPreference inits from `matchMedia.matches`, change event updates state, resolvedMode follows explicit mode vs systemPreference per `mode` value, dark class applied based on resolved mode, `toggleMode` cycles dark→light→system→dark); 1 new in `Layout.test.tsx` (toggle button title attribute walks the cycle); 4 new in `SettingsPage.test.tsx` (all three buttons render, active green border keys off `mode`, click switches mode, click fires toast). 26 previously-broken tests in `AddNotificationModal.test.tsx` + `NotificationProviderCardStockAlerts.test.tsx` + `CameraTokensPage.test.tsx` pass again post-`setup.ts` fix. Frontend build clean (2682 modules); i18n parity green at 4995 keys × 9 locales (+1 from `switchToSystem`). Contributor handled the entire round-1 review (matchMedia mock, locale parity, PR honesty, full test coverage, toast parity, `.map()` refactor for the button group) in a single revision push, no follow-ups deferred. -- **SliceModal: "Slice all plates" toggle for multi-plate sources** — Re-slicing a multi-plate 3MF (e.g. a "parted statue" project where each plate carries a different body part) required opening the slice modal once per plate, picking the printer / process / filaments every time, and ending up with one archive per plate. The footer now has a "Slice all N plates" checkbox for multi-plate sources: tick it and the "Slice" button flips to "Slice all N plates", submitting `plate=0` instead of the picked plate index. The backend forwards this as the BS CLI's `--slice 0` "all plates" sentinel, which produces a **single output 3MF whose `Metadata/plate_N.gcode` entries cover every plate** — one slice call, one archive, every plate inside. **Filament dropdowns also adapt**: with the toggle on, they show the *union* of every plate's slot usage (a slot a plate-2 part paints with but plate 1 doesn't was previously invisible — the user could only pick filaments for the actively-viewed plate). The union is computed client-side from the existing `platesQuery.data.plates[*].filaments` payload, so no extra round-trip. The backend `SliceRequest.plate` field's range relaxed from `ge=1` to `ge=0` to admit the sentinel (the schema's docstring spells out the three semantics: `None` → default plate 1, `0` → all plates, `>= 1` → that plate). The substitute-unused-filaments pass becomes a no-op for `plate=0` (no concept of "unused" when every plate counts), which is correct — in slice-all mode every slot the project defines IS used by something. The toggle is hidden on single-plate / STL sources where it'd be meaningless. **Cross-class slice-all is handled by a per-plate loop**: BS CLI's `--arrange` is project-wide, so `--slice 0 --arrange 1` on a cross-class source consolidates every plate's objects onto a single target bed — either packing everything onto one plate or rejecting with "Some objects are located over the boundary of the heated bed" when nothing fits. When Bambuddy detects `plate=0` combined with a class crossing, it falls back to slicing each plate independently (`plate=N, arrange=true`), then merges the N single-plate 3MF outputs into one multi-plate 3MF in `merge_plate_3mfs` — overlays each plate's `Metadata/plate_N.{gcode,gcode.md5,json,png,_small.png,no_light_N.png,top_N.png,pick_N.png}` onto the first plate's base 3MF and re-assembles `Metadata/slice_info.config` to list every plate's slice block. The resulting archive's totals are the sum of each plate's print time + filament usage. New `count_plates_in_3mf` parses `model_settings.config` for `` entries to know how many plate calls to make. Cost: N × per-plate slice time; for a 5-plate Mewtwo on H2D that's ~70s wall clock vs the single-call same-class path. **Progress toast shows loop position**: each per-plate sub-slice forwards the original `progress_request_id` + callback so the toast keeps showing the sidecar's stage messages, with the snapshot augmented with `multi_plate_index` / `multi_plate_count` — the toast renders "Plate 2 of 5 • Mewtwo.gcode.3mf — Generating G-code (47%) — 23s" instead of just elapsed time. New `slice.runningWithProgressMultiPlate` i18n key translated across all 9 locales. **Per-plate cover images preserved**: BS CLI with `--arrange` regenerates plate gcodes but rarely writes a fresh `Metadata/plate_N.png`, so the merged 3MF would have only plate 1's cover. The merger now takes the source 3MF as an optional fallback and lifts the source's per-plate render (`plate_N.png` / `plate_N_small.png`) into the merged file when the sliced output is missing it — same fallback approach as the archive-card thumbnail fix. **Final test coverage**: 26 unit tests in `test_slicer_3mf_convert.py` (extract canonical model, count plates, merge with overlay / passthrough / source-thumbnail fallback / sorted plates, substitute unused-slot filaments) + 3 in `test_slicer_api.py` (arrange flag wire format on preset and bundle paths) + 9 in `test_library_slice_api.py` (guard no-op semantics, re-sliced thumbnail / bed_type lifts, **a new cross-class slice-all integration test that mocks the sidecar, asserts the backend loops per-plate with `arrange=true`, and verifies the merged archive contains `plate_1..plate_N.gcode`**) + 2 in `test_archive_service.py` (Auxiliaries thumbnail fallback) + 4 in `SliceModal.test.tsx` (slice-all toggle sends `plate=0`, toggle hidden for single-plate, plus 2 pre-existing tests for the picked-plate behaviour) + 2 new in `SliceJobTrackerContext.test.tsx` (toast prefixes "Plate X of Y" when the snapshot carries the loop fields; no prefix on plain single-plate slices). 659 backend / 42 frontend tests green; backend ruff + frontend build + i18n parity all clean. 2 new tests in `SliceModal.test.tsx` (toggle sends `plate=0` to the backend; toggle hidden for single-plate sources) plus updates to the existing plate-picker test for the new label scheme. All 9 locales translated. Frontend build clean, i18n parity green at 4983 keys × 9 locales. -- **System Health — log scanner that surfaces self-fixable issues before they become support tickets** — Complements the active Connection Diagnostic with a passive check: it scans Bambuddy's recent app log against a curated catalog of known failure signatures and reports what it finds. The catalog (`backend/app/services/log_health.py`) is a deliberate allowlist — only known-bad, actionable patterns match, so a healthy install reports nothing and noisy benign churn (the occasional MQTT reconnect after a Wi-Fi blip) is gated behind a per-signature `min_count` threshold. Six seed signatures cover the recurring "layer 8" causes from the closed-issue triage: rejected access code, FTPS :990 timeout, FTPS TLS handshake failure, flapping MQTT connection, unreachable camera (RTSPS :322), and SQLite `database is locked` contention. Each finding is deduped (`occurred N×, last seen …`), classified as *you can fix this* / *environment* / *please report this*, and carries a deep-link to the troubleshooting wiki; sample log lines are sanitized (IPs, serials, access codes redacted) before they leave the process. Exposed via `GET /system/health` and surfaced on two surfaces that share one `SystemHealthPanel` component: a System Health section on the System page (on-demand re-scan), and inline in the bug reporter when the form opens — so a setup mistake gets self-resolved instead of becoming a GitHub issue. The Add-Printer and Edit-Printer dialogs also gained a setup-time pre-flight: saving now runs the connection diagnostic and, if a check fails, warns with a "save anyway" escape hatch instead of silently saving a printer that will immediately show offline. Log-reading and redaction primitives were extracted from `routes/support.py` into a shared `backend/app/services/log_reader.py` (behaviour-preserving). 13 backend tests (`test_log_health.py`, `test_system_api.py`) and 8 frontend tests (`SystemHealthPanel`, `BugReportBubble`, `AddPrinterPreflight`, `EditPrinterPreflight`); all strings translated across the 9 locales. Backend ruff clean, full unit suite green, frontend build clean, i18n parity green. -- **Event-loop stall watchdog — makes a frozen backend self-diagnose (#1486 groundwork)** — Several "container hangs after adding a printer" reports share a signature that leaves nothing to act on: the HTTP server goes silent, `/health` hangs, the process may stop responding to SIGTERM — and the logs just stop mid-stream with no traceback, because a frozen asyncio event loop cannot log anything. New `backend/app/services/loop_watchdog.py` closes that blind spot: an async heartbeat re-arms `faulthandler.dump_traceback_later()` every 10s, always 30s ahead. While the loop ticks, the timer is cancelled and re-armed before it can fire; if the loop stalls, the heartbeat can't re-arm and faulthandler's dedicated C-level timer thread — which runs independently of the frozen loop — dumps **every thread's stack to stderr**. The blocked frame then appears in `docker compose logs`, turning an un-diagnosable freeze into a one-command capture. Started in the app lifespan after migrations, stopped cleanly on shutdown; 30s threshold is well above any legitimate on-loop operation, so a trip always means a real bug. 5 unit tests in `test_loop_watchdog.py` (arms the timer, idempotent start, stop disarms + cancels, heartbeat interval below the threshold, survives a re-arm error). Backend ruff clean; full app lifespan verified via the integration suite. -- **Slicer: process & filament profiles filtered by the selected printer (#1325, requested by @IndividualGhost1905)** — In the server-side Slice dialog, picking a printer profile now filters the Process and Filament dropdowns to presets compatible with that printer; presets that resolve to a different Bambu model drop into a trailing "Other printers" group instead of cluttering the main list. Matching uses the slicer's own `compatible_printers` list for imported (local) presets, and falls back to the `@BBL ` name suffix for cloud and standard presets, so all three tiers are covered. Compatibility-unknown presets (custom or untagged) are never hidden. Defaults follow suit — the pre-picked process and per-slot filament now prefer a printer-compatible preset, and switching the printer re-picks any selection left incompatible. The printer and process dropdowns also default to the preset names embedded in the source 3MF's `project_settings.config` when those presets are available, instead of always taking the first listed preset. New `frontend/src/utils/slicerPrinterMatch.ts` (11 unit tests) and `extract_embedded_presets_from_3mf` (5 unit tests); `UnifiedPreset` now carries `compatible_printers`, exposed for the local tier (`backend/app/api/routes/slicer_presets.py`); the plates endpoints return `embedded_printer` / `embedded_process`. Parity green, build clean. -- **Spanish (es) translation (#1243, requested by @MiguelAngelLV)** — Bambuddy now ships a full European Spanish locale. New `frontend/src/i18n/locales/es.ts` translates all 4899 keys with placeholders, plural forms, and inline markup preserved; registered in `frontend/src/i18n/index.ts` and selectable as "Español" in the language picker. The parity checker auto-discovers the file — `frontend/scripts/check-i18n-parity.mjs` gained an `ES_COGNATES` allow-list for genuine Spanish cognates and brand/format tokens. Brings the supported-language count to 9 (en / de / es / fr / it / ja / pt-BR / zh-CN / zh-TW). Parity green, frontend build clean. -- **Currency: Belize Dollars (BZD) added to the Settings → Cost currency dropdown (#1454, requested by @PLGuerraDesigns)** — Reporter accurately tracks 3D-printing filament costs in his local currency and BZD wasn't selectable, forcing a manual 2:1 mental conversion from USD. Added `BZD: 'BZ$'` to `frontend/src/utils/currency.ts` next to MXN (Americas dollar-prefix grouping); `getCurrencySymbol('BZD')` returns `'BZ$'` and the SUPPORTED_CURRENCIES list now has 30 entries. Unit test added in `frontend/src/__tests__/utils/currency.test.ts` covering the symbol lookup and presence in SUPPORTED_CURRENCIES; entry-count assertion bumped to 30 so any future additions/removals are caught immediately. 14 currency tests green; frontend build clean. -- **Connection Diagnostic — self-service triage for "printer won't connect / won't print"** — A triage review of recently-closed issues found roughly a third were user-side setup errors (printer not in LAN developer mode, blocked ports, Docker bridge networking, wrong access code, printer on a different subnet), each costing a multi-round-trip "enable debug logging → build a support bundle → upload it" exchange. A new diagnostic (`backend/app/services/printer_diagnostic.py`) runs those checks automatically: TCP reachability of MQTT 8883 / FTPS 990 / RTSPS 322, LAN developer mode, Docker network mode, printer/host subnet match, and MQTT credential class — each returning a pass / fail / warn / skip status with a localized plain-language fix. Exposed via `GET /printers/{id}/diagnostic` (saved printer) and `POST /printers/diagnostic` (pre-save Add-Printer flow), and surfaced as a one-click "Run diagnostic" from the printer card actions menu (plus a quick button on the card when a printer is offline), the Add-Printer dialog, and a new Connection Diagnostic section on the System page. The in-app bug reporter scans configured printers when the report form opens and always shows the result — a healthy confirmation when nothing's wrong, or the detected problem and its fix inline — so setup mistakes get self-resolved instead of becoming GitHub issues. The GitHub `config.yml` troubleshooting link was repointed from the wiki source repo to the rendered troubleshooting page. Backend service unit tests (15) and frontend modal tests (3) added; all diagnostic strings translated across the 8 locales. Backend ruff clean, frontend build clean, i18n parity green. +- **MQTT auth rate-limit on the virtual printer** — Bambuddy's VP exposes an 8-char access code via the slicer-facing MQTT server on port 8883. Without a rate limit the code is brute-forceable by anyone who can reach the VP's bind IP (LAN, Tailscale, or any other tunnel the user chose to expose). The new per-IP limiter records each failed CONNECT auth attempt and rejects further CONNECTs from that IP once 5 failures occur within a 60 s window. The window is sliding (not cumulative), recovers automatically after expiry — no manual unblock — and successful auth clears the IP's prior failure history so a user who fat-fingered their code 3 times then got it right isn't penalised on their next reconnect. Per-IP tracker uses `time.monotonic()` so wall-clock jumps can't extend or shorten the window unexpectedly. Constants `_AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5` and `_AUTH_RATE_LIMIT_WINDOW_SECONDS = 60.0` are module-level for ops tunability. 5 unit tests in `test_vp_mqtt_server.py::TestAuthRateLimit` pin the under-limit/at-limit/window-recovery/multi-IP/success-clears semantics. +- **Per-slicer MQTT response routing for multi-slicer VP setups** — Pre-fix: when slicer A sent `extrusion_cali_get` (or any other bridge-forwarded command) to a non-proxy VP bound to a target printer, the printer's response was fanned out to **every** connected slicer — leaking slicer A's response into slicer B's command stream. Slicers ignore responses to sequence_ids they didn't send, but the leak is still wrong and could confuse multi-slicer-host setups (workstation + laptop both connected to the same VP). The fix records `sequence_id → originating client_id` in `SimpleMQTTServer._pending_requests` on the way out and looks it back up in `push_raw_to_clients` on the way in, routing the response only to that one client. Falls back to broadcast for printer-initiated unsolicited pushes (push_status etc. — every slicer expects these) and for sequence_ids the map never saw recorded (covers slicers subscribing mid-flight). Bounded at 256 entries with FIFO eviction so a slicer that sends commands without ever consuming responses can't leak memory. 6 unit tests in `test_vp_mqtt_server.py::TestPendingRequestRouting` cover seq-id capture across nested blocks, lookup-pops-entry semantics, FIFO eviction at cap, malformed-payload fallback, and broadcast on unrecorded seq. +- **H2D Pro virtual-printer support (experimental — needs field confirmation)** — Added SSDP model codes `O1E` and `O2D` to `VIRTUAL_PRINTER_MODELS` and matching `09400A` serial prefixes to `MODEL_SERIAL_PREFIXES` so the H2D Pro shows up in the Add Virtual Printer model dropdown and advertises a model code distinct from H2D's `O1D`. The codes were transcribed from the project's model-codes reference but have not been validated against a live H2D Pro's SSDP response. Anyone with an H2D Pro who picks this from the dropdown should confirm BambuStudio recognises the VP correctly; if not, the code values need a one-line correction and a follow-up release. +- **VP child-service readiness barrier** — Pre-fix: `VirtualPrinterInstance.start_server` spawned each child sub-service (FTP, MQTT, Bind, SSDP) as a `asyncio.create_task` and returned immediately. `is_running` then reported `True` even though the child sub-services' sockets were still in the gap between `asyncio.create_task(...)` and the inner `asyncio.start_server` returning. A caller racing the start (the diagnostic route, the VP-card UI poll, an integration test) could see `running=pass` while `port_ftps=fail`. Each child now exposes a `ready: asyncio.Event` that's set after the actual socket bind, and `start_server` awaits all of them with a bounded 5 s timeout. If a child hangs binding, the timeout logs a `Sub-service didn't bind within 5s: ...` warning and the VP continues — the existing task-tracking still catches the failure on the next iteration. The 5 s ceiling is well above any legitimate bind on healthy hardware; on a Pi 3 with a congested SD card it's tight but bounded. ### Changed - **Bug-report template: tightened fields + new Area dropdown to cut invalid-issue triage load** — 170 issues have been closed with the `invalid` label (61 of them in the last 30 days alone — roughly 1 in 5 of all closed issues), nearly always because the reporter hadn't run the in-app diagnostics or checked the documented troubleshooting page. The template now forces engagement with the tools that were already shipped. **Form changes** (`bug_report.yml`): (a) the "I ran the Connection Diagnostic" checkbox flipped from `required: false` to `required: true`, so the form blocks submission until the reporter has actually used the diagnostic (or knowingly lied — higher friction than reading the doc); (b) the Support Package textarea is now `required: true` instead of optional, with the field's prompt rewritten to "Drag the .zip here, or explain why you cannot attach one" so users without a working Bambuddy still have a path; (c) a new required "Troubleshooting steps already taken" textarea sits between Steps to Reproduce and the printer-model dropdown, asking which wiki pages were checked and which in-app diagnostics were run — empty answers can't submit, which produces either real evidence or an admission that nothing was tried (both of which are useful for triage); (d) the pre-form markdown intro now spells out the "search → wiki → diagnostic → support package" sequence with a citation of the 1-in-5 stat so reporters understand the *why* before they reach the fields; (e) the final-checks list grew from one to three required confirmations (searched issues + checked troubleshooting wiki + ran Connection Diagnostic for connection/printing/camera bugs), with the wiki-checked confirmation linking to the rendered troubleshooting page. **Bug categorization** (the gap that motivated the rewrite): the old single `Component` dropdown only carried `Bambuddy / SpoolBuddy / Both` — useless for area triage. Replaced with TWO required dropdowns: `Product` (Bambuddy / SpoolBuddy) and `Area` (15 options covering the actual feature surface — connection, dispatch, filament/AMS, slicer, VP, camera, archives, stats, queue, notifications, auth, updates, UI, integrations, SpoolBuddy kiosk, plus an Other escape hatch). **Auto-labeling** (`.github/workflows/auto-label-area.yml`): on every issue open/edit, an `actions/github-script@v7` step parses the Area dropdown out of the rendered issue body (matching the `### Area\n\nValue` block GitHub forms produce) and applies the matching `area:*` label. Tolerant of CRLF, the `_No response_` placeholder, and the issue-edit re-fire path (won't re-add an already-present label). Unrecognised Area values emit a `core.warning` so missed sync between the form and the workflow map shows up in Actions logs. Maintainer hand-off: 15 `area:*` labels need to be created once via `gh label create` (see commit message for the exact commands) — labels referenced by the workflow but missing in the repo cause the `addLabels` call to throw, so this prerequisite is load-bearing. Printer Model dropdown verified against `PRINTER_MODEL_MAP` in `backend/app/utils/printer_models.py` — all 13 current Bambu models present (X1 Carbon / X1 / X1E / X2D / P1S / P1P / P2S / A1 / A1 Mini / H2D / H2D Pro / H2C / H2S), no update needed. YAML syntax validated via Python `yaml.safe_load` for both the template and the workflow. -- **Settings → SpoolBuddy: CPU load tile added to the device card** — The SpoolBuddy daemon's heartbeat already reports `load_avg` (1/5/15 min) and `cpu_count` via `system_stats` (see `spoolbuddy/daemon/system_stats.py`), but the device card on the Bambuddy SpoolBuddy settings only rendered CPU temp / memory / disk / system uptime. Adds a fifth tile next to CPU temp showing the 1-minute load average alongside core count and a percent-of-cores readout — for a 4-core Pi: `1.20 / 4 (30%)`. Falls back to a bare load number when `cpu_count` isn't reported, and the tile is hidden entirely when the daemon doesn't emit `load_avg` (older builds). Useful for spotting the "I2C/SPI stuck after idle overnight" pattern early — sustained high load before the bus dies points at runaway daemon work rather than a kernel hang. Translated across all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW). Frontend build clean, i18n parity green. -- **Virtual printer: setup diagnostic + one-click slicer-certificate export** — Two recurring virtual-printer support pains, addressed on the Virtual Printers settings page. **(1) Setup check** — a new stethoscope action on each VP card runs `GET /virtual-printers/{id}/diagnostic` and shows a pass/fail/warn/skip checklist: VP enabled, services running, bind interface still exists, access code set, target printer (proxy mode), and — decisively — a live TCP probe of the FTP/MQTT/discovery ports on the bind IP. The manager swallows per-service start errors (`run_with_logging`), so a service object can exist while nothing is actually listening; probing the bind IP from outside is the only reliable signal, and it catches the common "VP doesn't show up in the slicer" bind-IP-conflict and stale-interface cases. New `backend/app/services/virtual_printer/diagnostic.py` + `VPDiagnosticResult` schema + `VirtualPrinterDiagnosticModal.tsx`. **(2) Slicer certificate** — virtual printers present a TLS cert signed by a shared CA the slicer must trust; until now users had to `docker exec` in and `cat bbl_ca.crt` to get it. A new "Slicer certificate" row on the Virtual Printers settings card (alongside the Archive name source toggle) offers Copy and Download (`bambuddy-virtual-printer-ca.crt`) plus the CA's SHA-256 fingerprint, served by `GET /virtual-printers/ca-certificate` — only the public certificate, never the CA private key. The CA is generated on demand so the button works before the first VP is enabled. Copy uses a non-secure-context fallback (Bambuddy is usually on plain-HTTP LAN), extracted into a shared `utils/clipboard.ts`. 9 backend diagnostic/CA unit tests + 4 route integration tests + 6 frontend tests (diagnostic modal, clipboard helpers); all `vpDiagnostic.*` / `virtualPrinter.caCert.*` strings translated across the 9 locales. Backend ruff clean, frontend build clean, i18n parity green. -- **Bug-report panel: connection diagnostic no longer overflows on multi-printer setups** — The "Report a Bug" panel scans every configured printer on open and surfaces connection problems inline so users can self-fix before filing. The first cut rendered a full ~6-row checklist for *each* problem printer stacked vertically; a user with many printers all reporting issues pushed the description box, screenshot uploader and Submit button far below the fold in the `max-w-md` / `max-h-[80vh]` panel. The diagnostic section is now a compact summary — one line ("N of M printers have connection issues") followed by the affected printers as collapsed rows (healthy printers count toward M but render no detail). Each row expands on demand to that printer's full checklist via the shared `Collapsible` widget; when exactly one printer has problems the row is auto-expanded since that's the case where inline detail is wanted with no extra click. The panel now stays a fixed ~3 lines plus one row per affected printer regardless of fleet size, keeping the report form reachable. Healthy-fleet confirmation line is unchanged. New `bugReport.diagnosticSummary` key (with `{{problems}}`/`{{total}}`) replaces the static `diagnosticHeading`; `diagnosticIntro` reworded to be printer-count-neutral and point at the expand affordance — both translated across all 9 locales. 2 new tests in `BugReportBubble.test.tsx` (multiple problems stay collapsed and expand on click; a single problem auto-expands); 11 tests green; frontend build clean; i18n parity holds at 4903 keys × 9 locales. -- **Color Catalog sync now identifies itself as Bambuddy to filamentcolors.xyz** — The FilamentColors.xyz sync client in `inventory.py` created its `httpx.AsyncClient` with no `User-Agent`, so it leaked httpx's default `python-httpx/x.y` string — the only outbound client that did (`bambu_cloud`, `makerworld`, `firmware_check` all send the honest `Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)`). It now sends the same honest UA, consistent with the rest of the codebase. Surfaced while investigating #1456 (a Cloudflare `403` on the sync that turned out to be the reporter's network/IP reputation, not Bambuddy — the UA leak was a separate inconsistency found in passing, and this change does not by itself resolve a Cloudflare IP block). -- **Filament inventory: grouped rows now show group totals (#1368, requested by a user)** — With "Group similar" enabled, the collapsed group row showed the values of a single member (the first spool) — so a group of five 1 kg spools displayed "1000 g" instead of the 5 kg it actually held. The group header now aggregates across all members: the table view's Label, Net, Gross, Used and Remaining columns and the grid card's weight figure show group totals, while identity columns (Material, Brand, Colour) and the Cost/kg rate stay per-spool-correct. Per-spool-only fields with no meaningful total (dates, location, note, tag ID) keep showing the representative member's value; the expanded individual rows are unchanged. New `aggregateGroupSpool` helper in `frontend/src/utils/inventoryGrouping.ts` with 4 unit tests. Frontend-only — all data was already in the spool list. — Previous behaviour disabled the Slice button whenever the source 3MF's bound printer model didn't match the user's picked printer profile, on the theory that the slicer CLI "cannot re-slice a 3MF for a different printer" and would silently fall back to embedded settings to produce a wrong-printer file. Step 0 empirical test on 2026-05-20 disproved that: an 18-color H2D-bound `Trent900.3mf` sliced via the X1C bundle (`POST /slice` with `bundle=cb…X1C, printerName=# Bambu Lab X1 Carbon 0.4 nozzle`) produced 2.3 MB of genuinely X1C-compatible G-code in 1.8 s — `printer_model` overridden to `Bambu Lab X1 Carbon`, `printable_area` to 256×256 (X1C bed, not H2D's 350×320), `printable_height` 250 (vs 325), `bed_exclude_area` populated with X1C's 18×28 corner zone, `nozzle_diameter` single 0.4 (vs H2D's dual `0.4,0.4`), and the full X1C `machine_start_gcode` sequence baked in. The sidecar takes printer / process / first-N filament names from the picked bundle and only inherits embedded values for unused trailing slots — bed size, kinematics, start sequence all come from the target. **Behavioural change**: dropped `!printerMismatch` from the SliceModal `isReady` predicate so the Slice button stays enabled when models differ. The amber banner was first softened to an info message, then removed entirely — re-slicing across printers is now just a normal slice, the picker UI already shows which printer was picked, no second confirmation needed. **Dead-code removal (same drop)**: with no banner, the `source_printer_model` field on the `/library/files/{id}/plates` and `/archives/{id}/plates` responses had zero consumers; the `extract_source_printer_model_from_3mf` helper in `threemf_tools.py` (which opened the 3MF zip and read `Metadata/project_settings.config` on every plate request) had zero callers. Removed both response keys, both backend extractions, both `threemf_tools` imports, the helper itself, its 6 unit tests, the `source_printer_model` field from `frontend/src/types/plates.ts` (PlateMetadata + LibraryFilePlatesResponse), and 2 obsolete SliceModal tests that exercised the now-impossible matched-printer / legacy-archive paths. **i18n discipline cleanup (same drop, per [[feedback_no_followups]] + [[feedback_translate_dont_fallback]])**: every t() callsite in SliceModal.tsx had an inline English `defaultValue:` or positional-second-arg English fallback — 22 sites in total. With 8 locales shipped, those fallbacks are dead weight at best, and an actual i18n-violation when the key is missing because non-English users would silently see English. Audit found 3 keys (`slice.bundle`, `slice.bundleNone`, `slice.bundleAllRequired`) that had **no** corresponding entry in any locale file — they were being served from the inline English fallback exclusively, meaning every non-English user was already seeing those three labels in English. Added all 3 to all 8 locales with real translations, then stripped the English fallback from every t() call in SliceModal.tsx. The `slice.printerMismatch` key was removed from all 8 locales (banner is gone). **Why this matters**: a recurring pain point for users importing MakerWorld project files where the original creator's printer often differs from the user's; previously they had to round-trip through BambuStudio's "convert project" flow to re-export. Now Bambuddy re-slices in-place with no UI friction. **Tests**: the existing SliceModal "shows mismatch warning AND disables Slice" test was rewritten to assert "does not surface any cross-printer banner AND keeps Slice enabled when models differ" (regression guard against the gate being re-added); 2 obsolete tests deleted. 32 SliceModal tests green (was 34, -2 dead tests); 49 threemf_tools tests green (was 55, -6 helper tests); 24 plates-route tests green; frontend build clean; backend ruff clean; i18n parity check passes 4858 keys × 8 locales (net +2 vs pre-fix: +3 bundle keys, -1 printerMismatch). +- **VP virtual-printer FTP server: cmd_STOR streams chunks straight to disk instead of buffering the whole upload in memory** — Pre-fix: ``cmd_STOR`` accumulated every chunk in a ``list[bytes]`` and called ``write_bytes`` at the end. Peak RSS for a multi-GB ``.gcode.3mf`` (multi-plate dense prints) was ~2× the file size — chunks held + the ``b''.join`` of them — and could OOM-kill a low-memory host (Pi 3, low-end Synology, etc.). The streaming rewrite writes each 64 KiB chunk to ``file_path.open("wb")`` inline as it arrives, bounding peak memory at one chunk regardless of total upload size. Wire protocol unchanged — same ``150 → 226`` sequence, same destination path, no new verbs, no concurrency guard. The visible difference is that the destination file grows progressively rather than appearing all-at-once on completion; slicers don't ``LIST`` during ``STOR`` so this isn't observable. Same change adds a ``MAX_UPLOAD_BYTES = 4 GiB`` hard cap — a runaway or malicious client can no longer drive RSS or disk to exhaustion. On the cap path the partial file is unlinked so a slicer retry starts clean. 4 unit tests in ``test_vp_ftp_stor.py`` (happy-path bytes on disk + 226, cap-violation 426 + partial cleanup, mid-stream read error cleanup, MAX_UPLOAD_BYTES sanity floor). +- **VP virtual-printer FTP passive port range widened from 50000-50100 (101 ports) to 50000-51000 (1001 ports)** — The original range was sized for a single VP. With multiple VPs each running their own FTP server, concurrent passive data connections compete for the 101-port pool and the bind-retry loop's 10 random picks can collide; 1001 ports gives headroom. Only affects the **non-proxy** path (``VirtualPrinterFTPServer.PASSIVE_PORT_MIN/MAX``). The proxy path's ``SlicerProxyManager.FTP_DATA_PORT_MIN/MAX`` stays at 50000-50100 because it pre-binds the printer-side range exactly. Docker bridge-mode users mapping the old range need to update to ``50000-51000:50000-51000`` — `docker-compose.yml`, `install/docker-install.ps1` warning, and the wiki (`docs/getting-started/docker.md`, `docs/features/virtual-printer.md` — port table, two UFW rules, two firewalld rules, Cloudflare-tunnel list, firewall troubleshooting line) all updated with "widened in 0.2.5" notes. Docker host-mode and bare-metal users are unaffected (no port mapping involved). The proxy-mode FTP-data row in the wiki stays at 50000-50100 because that path is unchanged. +- **VP MQTT bridge sticky-keys: 7 more fields preserved across incremental pushes** — Pre-fix: when the bridge cached a real printer's ``push_status``, the very next 1 Hz incremental push (which only carries changed temps / fan / wifi_signal) wiped any field not in the sticky-keys allowlist. The cached state lost ``upgrade_state``, ``xcam``, ``hw_switch_state``, ``nozzle_diameter``, ``nozzle_type``, ``online`` and ``ams_status`` after a single tick — BambuStudio's Send pre-flight reads several of these (``upgrade_state.dis_state`` / ``force_upgrade`` in particular) and could refuse Send because the cached push said "unknown firmware state". Same shape as #1228 (storage indicators) and #1558 (live-progress fields) — the cached-branch field-shape parity, not a new mechanism. Sticky-keys carry-forward is now also a ``copy.deepcopy`` (was reference) so a future merge that mutates a carried-forward dict in place can't corrupt both copies. +- **VP target-printer DHCP IP / serial refresh now restarts proxy VPs** — Pre-fix: when a target printer's IP changed (DHCP renewal, network reconfiguration), the running proxy VP kept forwarding to the stale IP forever because ``sync_from_db``'s "changed" predicate didn't compare ``proxy_ips`` against the running instance's ``target_printer_ip`` / ``target_printer_serial``. The user had to manually toggle the VP to refresh. Now ``sync_from_db`` re-evaluates the proxy target each cycle and restarts the VP when the IP or serial actually changes — same code path as a config change. If the target printer's DHCP lease cycles frequently this means more proxy restarts, but the alternative was silent breakage; documented in the release-notes for users on flaky-DHCP networks. +- **VP queue_force_color_match setting takes effect immediately** — Pre-fix: toggling the per-VP ``Force exact color match`` setting via the UI silently no-op'd because ``sync_from_db``'s "changed" predicate didn't include the field. The user had to restart the process for the new value to land. The predicate now also checks ``queue_force_color_match`` so the running instance gets restarted on toggle. +- **VP MQTT client session errors elevated from DEBUG to WARNING** — The outer ``except Exception`` in ``SimpleMQTTServer._handle_client`` was logging at DEBUG, which production deployments default to suppressing. Users reporting "slicer disconnects randomly" then had no signal to pass us. WARNING surfaces it. Inner handlers' expected parser/IO failures stay at DEBUG — only unexpected errors that would otherwise reach the outer catch get visibility. + ### Security -- **idna: bump to `>=3.15` to clear CVE-2026-45409 (ReDoS in `idna.encode()` with crafted Unicode payloads, e.g. `"٠" * N` or `"・" * N + "漢"`)** — Transitive dep pulled in by anyio / httpx / requests / yarl; not directly pinned, which is why it lingered at 3.13. Added an explicit `idna>=3.15` floor in `requirements.txt` between Authentication and HTTP-client blocks with a comment explaining why it's pinned (so a future downstream loosening doesn't silently downgrade us). Verified via `pip-audit` clean post-upgrade. -- **starlette: bump floor to `>=1.0.1` to clear PYSEC-2026-161** — `starlette` is a transitive dep pulled in by fastapi, whose range still admits the vulnerable 1.0.0 build, so a fresh `pip install` would silently pick it back up. Added an explicit `starlette>=1.0.1` floor in `requirements.txt` under the urllib3 pin with a why-comment matching the same pattern as the idna/urllib3 entries. Release-notes reviewed for both 1.0.1 (single fix: ignore malformed `Host` header when constructing `request.url`) and 1.1.0 (the resolver actually picked up 1.1.0): three behavioural changes — `FileResponse` falls back to `application/octet-stream` when `mimetypes.guess_type()` can't resolve (Bambuddy has 2 `FileResponse` calls without explicit `media_type`, both serving `index.html` where guess_type still resolves to `text/html`, plus custom-icon serving in `external_links.py:261` where the new fallback is a security improvement), `HTTPEndpoint` only dispatches standard HTTP verbs (`grep` found zero `HTTPEndpoint` usages in Bambuddy — pure FastAPI router code), `StaticFiles.lookup_path` rejects absolute paths in *requests* (the 4 mounts in `main.py:5503-5525` pass absolute *base directories* to the constructor, which is unaffected — only path-traversal-style request paths get rejected). Full backend test suite green (5300/5301; the 1 failure is a pre-existing `-n 30` parallelism flake unrelated to starlette and passes in isolation). Verified clean via `pip-audit` post-upgrade. -- **PyJWT CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): permanently ignored in pip-audit** — Advisory is disputed by the PyJWT maintainers, with the advisory description literally noting *"this is disputed by the Supplier because the key length is chosen by the application that uses the library."* `fix_versions=[]` on the advisory confirms no PyJWT patch exists or will exist. Bambuddy is not affected: `backend/app/core/auth.py:184` auto-generates secrets via `secrets.token_urlsafe(64)` (~86 chars of entropy, far above any sane minimum) and the file-loaded path at `:177` rejects secrets shorter than 32 chars. Added a permanent `--ignore-vuln CVE-2025-45768` to `.github/workflows/security.yml` with an inline comment citing the file:line evidence so a future maintainer reviewing the ignore list sees why it's load-bearing. Also dropped the stale `--ignore-vuln CVE-2026-4539` for Pygments — Pygments has since shipped a patched version and the ignore is no longer load-bearing (verified: `pip-audit --ignore-vuln CVE-2025-45768` alone reports clean). - **Trivy DS-0026 (`Dockerfile.test` missing HEALTHCHECK): silenced via `HEALTHCHECK NONE`** — The test image runs `pytest` and exits; there is no long-running service to probe, so any HEALTHCHECK we added would be cargo-cult noise. `HEALTHCHECK NONE` is the documented Docker directive to explicitly opt out of any inherited healthcheck and is the way Trivy expects projects to signal "this image is not a service." Closes code-scanning alert #813. +- **VP access codes now compared with `hmac.compare_digest` (constant-time)** — Pre-fix: both `FTPSession.cmd_PASS` and `SimpleMQTTServer._handle_connect` used Python's `==` operator on the 8-char access code. Constant-time comparison closes the timing-side-channel without changing the protocol surface. Same auth, no UX change. +- **VP MQTT brute-force rate-limit per source IP** — 5 failed CONNECT attempts within a 60 s sliding window block further auth attempts from that IP for the rest of the window. Auto-recovers — no manual unblock. Constants `_AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5` / `_AUTH_RATE_LIMIT_WINDOW_SECONDS = 60.0` are module-level for ops tunability. See Added section for full description. +- **VP `access_code` no longer leaked in DEBUG logs** — Pre-fix: `PUT /virtual-printers/{id}` logged `body.model_dump(exclude_unset=True)` at DEBUG, which dumped the plaintext access code whenever the user saved a new one. Now the field is redacted (`***`) before the log emission. Violation surfaced by no-secrets-in-logs audit; not exploitable in the field (DEBUG is off by default) but is exactly the kind of leak the rule exists to prevent. +- **VP FTP upload capped at 4 GiB (DoS guard)** — `cmd_STOR` now rejects an upload that crosses `MAX_UPLOAD_BYTES = 4 GiB`, deletes the partial file, and replies 426. Without the cap a runaway or malicious client could drive RSS or disk to exhaustion; 4 GiB is well above any realistic multi-plate `.gcode.3mf`. Same code path adds the streaming rewrite (see Changed section for details). ### Fixed - **Sliced `.gcode.3mf` files now render in the 3D preview and expose a Preview-3D action in the file row (#1543, reported by @Vlado-Tarakan)** — Reporter exported a multi-plate `.gcode.3mf` from Bambu Studio to the shared folder Bambuddy watches and the 3D preview tab came up empty; if he re-uploaded the same file via the file manager, the preview worked. Root cause: two paths classify `file_type` differently. `backend/app/api/routes/library.py:1343-1348` (the shared-folder scan path) does a compound-extension check and tags the file `gcode.3mf`; the upload path at the same file's `1588` does a single `ext[1:]` and tags it `3mf`. Then `frontend/src/components/ModelViewerModal.tsx:71-73` had `hasModel = normalizedType === '3mf' || 'stl'` and `hasGcode = normalizedType === 'gcode' || '3mf'` — neither matched `gcode.3mf`, so the capabilities object landed with both flags false and the modal rendered an empty bed. `FileManagerPage.tsx:858` also gated the Preview-3D context action on `file_type === '3mf' || 'gcode' || 'stl'`, so for shared-folder files the entry didn't even appear, and the type pill at `765-770` had no colour case for `gcode.3mf` so it fell through to the generic gray. **Fix** (frontend-only, no backend churn): `ModelViewerModal.tsx` introduces an `isThreeMfFamily = normalizedType === '3mf' || normalizedType === 'gcode.3mf'` predicate used in two places — the capabilities branch (`hasModel = isThreeMfFamily || 'stl'`, `hasGcode = isThreeMfFamily || 'gcode'`) and the plates-loading branch that previously hard-gated on `!== '3mf'` and would have returned `setPlatesData(null)` for the shared-folder file. `FileManagerPage.tsx` adds `gcode.3mf` to the Preview-3D action gate and shares the gcode blue type-pill colour so sliced-output files are visually distinguishable from source 3MFs. The compound `gcode.3mf` classification on the backend is intentionally preserved — it carries useful "this is a sliced output" semantics that other UI surfaces could use later. The `canOpenInSlicer` and `sliceableType` checks at `ModelViewerModal.tsx:269, 277-280` are deliberately left alone — a sliced output isn't openable in the slicer, and `sliceableType` already explicitly excludes `.gcode` and `.gcode.3mf` per the comment "the file type can't be sliced". **Out of scope** (separate Bambu-Studio format limitation, not a Bambuddy bug): Vlado's secondary observation that the upload-path 3D preview "shows only one plate" even though his project has 5 plates — Bambu Studio's `.gcode.3mf` export contains the g-code and model data for the active plate only, not the entire multi-plate project. The print picker enumerates plates via `gcode_*.gcode` entries inside the zip (a separate code path), which is why the user can still pick the plate at print time. The empty-bed fix is the data point that closes the user-visible bug. **Tests**: existing full 2043-test frontend suite green; no test asserted on the unsupported `gcode.3mf` capabilities branch (the change is additive — `3mf` and `stl` and `gcode` behaviours are unchanged). Frontend build clean. @@ -44,6 +44,48 @@ All notable changes to Bambuddy will be documented in this file. - **SpoolBuddy weight sync no longer silently lands on a stale local row when Spoolman is enabled (#1530, reported by @chesterakl)** — Reporter (Spoolman mode, H2C, internal "manually add then NFC-link" flow) saw the SpoolBuddy "Sync Weight" button flip to "Synced!" but the Spoolman-backed inventory listing never updated. Cause: `POST /spoolbuddy/scale/update-spool-weight` (`backend/app/api/routes/spoolbuddy.py`) ran the lookup local-DB-first and only fell through to Spoolman on local miss — but the upstream `nfc/tag-scanned` route is exclusive (always-Spoolman when `spoolman_enabled=true`, after the #1119 / nfc-routing fix). When the user's local DB still held a stale `Spool` row that happened to share a numeric id with the Spoolman spool the NFC tag mapped to, the sync endpoint absorbed the update into the stale local row, returned 200 with the local `weight_used`, and the actual Spoolman spool went untouched. The support log confirms it: 17 sync attempts across two days, every line logged `SpoolBuddy updated spool 2 weight: …g on scale, …g used` (the local-branch log format) and the `SpoolBuddy updated Spoolman spool …` line (which only fires in the Spoolman branch) never appeared. The bug couldn't be reproduced on developer setups because they don't carry a leftover local row with a colliding id. **Fix**: `update_spool_weight` now routes exactly like `nfc_tag_scanned` — `_get_spoolman_client_or_none(db)` first, and that result picks the branch exclusively. Spoolman mode goes straight to Spoolman with no local-DB read; local mode does the local update and returns 404 (not "fallback to Spoolman") on a local miss. Matches [[feedback_inventory_modes_parity]] — the two inventory modes must behave identically from the user's perspective, including which row gets written. The docstring now spells out the routing contract so the next reader doesn't reintroduce the local-first read. **Tests**: 1 new regression test in `TestUpdateSpoolWeightSpoolman.test_stale_local_row_does_not_shadow_spoolman` — creates a local `Spool` with the same numeric id as a mocked Spoolman spool, posts the sync, asserts (a) Spoolman's `update_spool` was called with the correct remaining weight, and (b) the local row's `weight_used` and `last_scale_weight` are unchanged after a `refresh()` against the live DB. The existing 8 tests in that class continue to assert the Spoolman branch math (filament/spool-level tare priority, 404 / 503 mappings, 250g fallback warning). 9/9 green; 126/126 across the spoolbuddy + spoolman-filament-patch integration suites green under `pytest -n 30`. **Cleanup hint for affected users**: anyone in Spoolman mode with leftover local Spool rows from before they switched should delete those rows — they're inert under the new routing, but they were eating sync attempts under the old. Backend ruff clean. - **Paused prints no longer inflate maintenance hours (#1521, reported by @TempleClause)** — The `track_printer_runtime` background task in `backend/app/main.py` counted both `RUNNING` and `PAUSE` states equally toward `runtime_seconds`, which feeds every hours-based maintenance interval (lubricate rods, clean nozzle, check belts, etc.). Maintenance items measure *mechanical wear*, and pause time involves no motion — so a print paused overnight stretched the maintenance clock forward by ~8 h without any actual wear, triggering "lubricate rods" warnings earlier than warranted. Reporter found this by code review (no support bundle), flagged it cleanly with the exact line in `main.py` and three ranked solution options. **Fix**: option 1 (exclude PAUSE entirely) — `state.state in ("RUNNING", "PAUSE")` → `state.state == "RUNNING"`. PAUSE now follows the same path as FINISH / IDLE / PREPARE: the elapsed-time accumulator skips it, and `last_runtime_update` is cleared so a later RUNNING transition starts fresh and doesn't back-bill the pause. No setting / toggle (reporter's option 3 was deliberately the throwaway — this is a wear-tracking semantic, not a user preference); no cap (option 2) — wear during pause is zero, not "reduced". Docstring and field-comment trail updated across `main.py`, `models/printer.py:23`, and the two `api/routes/maintenance.py` route docstrings that all previously described the field as covering "RUNNING and PAUSE states". **Out of scope**: retroactive backfill of existing `runtime_seconds` values — already-accumulated pause time cannot be split out, only future accumulation is fixed. Users with hours-based maintenance intervals already set will see slower accumulation going forward (the correct outcome), so a previously-near-due item may take longer to ring than under the old behaviour. **Tests**: 3 new in `test_runtime_tracking_pause.py` pinning the new contract — PAUSE does NOT accumulate and clears `last_runtime_update`; RUNNING still accumulates and updates the timestamp; a non-active state (FINISH) clears `last_runtime_update` to prevent back-billing the idle time when the printer next goes RUNNING. The tests drive the actual `track_printer_runtime()` coroutine through a single iteration via patched `asyncio.sleep` against an in-memory SQLite DB, so they catch any regression in the predicate at the call site (not just an extracted helper). Backend ruff clean; targeted 24-test rod/runtime subset all green. - **Quick Stats: user-cancelled prints now have their own bucket and no longer drag down the Success Rate gauge (#1390 follow-up, reported by @IndividualGhost1905)** — Reporter saw `Total prints: 20 / Success: 18 / Failed: 1` and asked where the 20th print went; the breakdown only showed Successful + Failed, so a cancelled run silently inflated the total without appearing anywhere. The earlier #1390 round had committed a test that *locked in* the bug — `it('uses total_prints as denominator so cancelled/stopped events count')` asserted the gauge should divide by `total_prints`, which lumped user/queue-cancelled jobs in with quality outcomes and conflated user intent with printer performance. **Cause**: `PrintLogEntry.status` has six values in production (`completed`, `failed`, `aborted`, `stopped`, `cancelled`, `skipped`) but the Quick Stats endpoint in `api/routes/archives.py` only counted two — `completed` → Successful, `status == "failed"` → Failed — and used a raw `count(*)` for Total Prints, so the other four statuses ended up in Total without surfacing in any breakdown row. `aborted` was particularly silent: classified as a failure elsewhere in the codebase (`failure_analysis.py`, `main.py:430,1729`) but not counted toward `failed_prints` in stats. **Fix**: three-bucket classification across the whole stats surface, matching how the rest of the codebase already groups these statuses. Quick Stats now returns `successful_prints` (completed), `failed_prints` (failed + aborted — printer-detected quality failures), and a new `cancelled_prints` (stopped + cancelled + skipped — user/queue interruptions). The SuccessRateWidget gauge divides by `successful + failed` only, so cancelling a roll because you changed your mind doesn't ding the printer's success rate — a Cancelled row in the breakdown surfaces the count so it doesn't silently vanish from Total Prints. The Failure Analysis service applies the same denominator change (`failure_rate = failed / (successful + failed)`) to both the headline rate and the per-week trend, so a week with no failures but several cancellations no longer reads as a misleading 0/N. **Schema change is additive-safe**: `ArchiveStats.cancelled_prints` defaults to `0` so any historical fixture validating against the model still parses; the frontend type also defaults the display to `0` when the field is missing. **i18n**: new `stats.cancelled` key with real translations across all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW) per [[feedback_translate_dont_fallback]]; parity script clean at 4994 leaves per locale. **Tests**: existing `it('uses total_prints as denominator …')` test inverted to assert the new behaviour (40 completed / 20 failed / 35 cancelled → gauge shows 67%, Cancelled row reads 35), `cancelled_prints: 0` added to the shared mock so the unchanged-display assertion (140/150 → 93%) still holds since `140 / (140 + 10) = 93.33%` rounds identically. 33 StatsPage tests + 6 backend stats/failure tests green; frontend build + backend ruff clean. **Follow-up (cosmetic):** the new Cancelled row's Ban icon rendered in `text-bambu-gray` while the Successful and Failed icons used semantic `text-status-ok` / `text-status-error` tokens — reporter (@IndividualGhost1905) noted the asymmetry and asked for an orange to match what Archives + notification badges use for cancelled. Switched the Cancelled row to `text-status-warning` (amber-500, same token family as the other two rows), so all three icons are now semantic-token-driven and the new row matches the colour the user already associates with cancelled status elsewhere in the UI. +- **VP queue mode no longer blocks BambuStudio Send while the target printer is mid-print (#1558, reported by @phieb)** — Reporter set up a non-proxy queue-mode VP with a target printer bound, started a print on the real printer, then tried Send to the VP from BambuStudio — slicer refused with the "busy" pre-flight error even though Bambuddy's whole job is to look idle so jobs queue any time. Cause traced by reporter: `SimpleMQTTServer._send_status_report` forces `gcode_state=IDLE` and storage indicators on top of the cached-as-base mirror — good — but the cached branch overrode only a handful of fields, and the live print-progress fields from the mirrored real `push_status` (mc_print_stage, mc_percent, mc_remaining_time, stg, stg_cur, layer_num, total_layer_num, print_error) passed through unchanged. The VP emitted a contradictory report (gcode_state=IDLE but mc_percent>0, stg_cur>0, ...) and BambuStudio's Send pre-flight read it as busy. Without a bound target the synthetic-stub branch reported all of these idle and Send worked — isolating the leak to the cached branch. **Fix**: in the cached branch, also override those 8 activity fields to the idle values the synthetic-stub branch uses (`mc_print_stage=""`, `mc_percent=0`, `mc_remaining_time=0`, `stg=[]`, `stg_cur=0`, `layer_num=0`, `total_layer_num=0`, `print_error=0`). Same shape as the #1228 storage-indicator overlay — internally consistent with the forced IDLE state while AMS / version / temperatures keep mirroring. **Behavioural caveat for users**: a slicer connected to the VP just for monitoring no longer sees the real printer's mid-print progress through the VP (since the cached push now reports idle). The real printer's IP / UI remains the source of truth for progress. Per the issue intent, this trade-off is explicit. **Tests**: new `test_live_progress_fields_zeroed_in_cached_branch` in `test_vp_mqtt_bridge.py::TestStatusReportCachedAsBase`. +- **VP `_pending_files` / temp-file leak on every error path across the three file handlers** — Pre-fix: `_archive_file`, `_queue_file`, and `_add_to_print_queue` only popped `_pending_files` and unlinked the temp file on the success branch. When archival failed (DB outage, ArchiveService raise, queue insert error), the entry stayed in the dict — and since the FTP layer keys its "same-name STOR already in flight" guard on filename, the slicer's next retry was spuriously rejected; the upload_dir also accumulated orphan temp files indefinitely. Each handler now uses a `try / finally` that pops the marker and unlinks the temp file regardless of whether the body succeeded. 3 unit tests in `test_virtual_printer.py::TestVirtualPrinterInstance` (one per handler) inject a failure mid-flight and assert both invariants. +- **VP queue position now picks `MAX(position)+1` instead of hardcoded `1`** — Pre-fix: VP-uploaded queue items always landed at `position=1`. With non-empty queues this created duplicate position=1 rows; the scheduler orders by `(printer_id, position)` so ties resolved in undefined DB-internal order, and repeat VP uploads accumulated multiple position=1 rows — making the queue's visible ordering non-deterministic and dispatching out of the user's intended sequence. Now the VP path runs the same `SELECT MAX(position) FROM print_queue_items WHERE printer_id= AND status='pending'` query the canonical `POST /print-queue/` route uses and inserts at `max_pos + 1`. Defensive `try/except` around the `.scalar()` call so a mocked DB in tests can't cause a `TypeError` from MagicMock arithmetic. 1 unit test pins the MAX+1 behaviour (with `MAX=7` the inserted item lands at `position=8`). +- **VP DELETE route cleans orphan `PendingUpload` rows + on-disk upload_dir** — Pre-fix: `DELETE /virtual-printers/{vp_id}` stopped the running instance and removed the row, but the `base_dir/uploads//` directory and any `PendingUpload` rows that referenced it lingered. The user only learned the rows were orphaned by trying to archive one and getting a "file missing" → flip-to-discarded auto-handler — not exactly a clear signal. Now the DELETE handler queries `PendingUpload` rows whose `file_path` starts with the VP's upload_dir prefix, marks them `status='discarded'`, then `shutil.rmtree`s the directory after the DB commit succeeds (so a crash between commit and rmtree leaves orphan files at worst, not orphan rows pointing at a missing tree). 2 unit tests in `test_vp_delete_cleanup.py` cover the cleanup-with-orphans + clean-no-op paths. +- **VP `MQTTBridge._refresh_loop` crash no longer leaks the raw_message_handler** — Pre-fix: if any exception escaped `_resolve_client` (the IP-encoding branch was the most likely culprit), `_refresh_loop` caught it with `logger.exception` and returned. The task completed `status=done` — not cancelled, not raising — so `stop()` never ran and `_unbind_client` never fired. `self._on_printer_raw` stayed registered on the live `BambuMQTTClient` and kept reading / writing `self._latest_print_state` on every real-printer message even though the VP bridge was functionally dead, creating a behaviour leak that persisted across VP restart. Now the crash exit explicitly calls `_unbind_client()` so the orphaned handler is detached even when the loop dies abnormally. +- **VP `sync_from_db` serialised by `asyncio.Lock` (concurrent-PUT race)** — Two simultaneous `PUT /virtual-printers/{id}` calls (e.g. browser racing the auto-save trigger) could race the inner start/stop sequence and leave duplicate sub-services bound to the same port — split-brain state that only resolved on the next Bambuddy restart. `VirtualPrinterManager.__init__` now holds a `_sync_lock`; `sync_from_db` wraps the body in `async with self._sync_lock`. Single VP updates still complete in well under a second, so the serialisation isn't visibly slower. +- **VP `_slicer_print_options` cache bounded at 128 entries with FIFO eviction** — Pre-fix: the dict that stashes the slicer's `project_file` options (so `_add_to_print_queue` can inherit timelapse / bed_leveling / flow_cali / etc.) had no bound. If the slicer sent `project_file` for a filename whose FTP upload was rejected / cancelled / non-3MF, the stash was orphaned and the dict grew one entry per such event for the VP's entire uptime. The new bound triggers eviction of the oldest entry once 128 entries accumulate. +- **VP `MQTTBridge` sticky-key carry-forward now uses `copy.deepcopy`** — Pre-fix: a sticky key carried over from the previous cache was assigned by reference, sharing nested dicts/lists between the old and new state. No current code path mutates a carried-forward sticky key in place, so this was latent — but a future merge that did would corrupt both copies. Defensive `copy.deepcopy` on the carry-forward removes the foot-gun without changing observable behaviour. +- **VP `MQTTBridge._refresh_loop` and `SimpleMQTTServer._send_status_report` cached-path use deepcopy** — `_send_status_report` cached branch was using `dict(cached)` — a shallow copy. Today's mutations are top-level only, but a future override that wrote into a nested dict (e.g. `online`, `upgrade_state`, `ipcam`) would corrupt the bridge cache and be read by every subsequent subscriber until the next real-printer push landed. Switching to `copy.deepcopy` removes the foot-gun. +- **VP `SlicerProxyManager` lifecycle hardening** — Multiple proxy-mode fixes shipped together: (a) `_ftp_data_proxies` and `_actual_ftp_port` are pre-initialised in `__init__` instead of `start()`, so `stop()` called before `start()` finishes (rapid mode-switch race) no longer raises `AttributeError` and leaves sockets stranded; (b) `_actual_ftp_port` now tracks the iptables-redirect target when the deployment uses `REDIRECT --to-port` to let non-root containers serve on 990, and `get_status()` returns it — diagnostic was previously probing the class constant 990 and false-failing on every working redirect deployment; (c) the FTP-data-proxy `auto_close` tasks (101 of them in `FTPTLSProxy`) are now tracked on `_auto_close_tasks` and cancelled in `stop()` — previously they lingered ~60 s holding server references and could fail the next start with "address already in use"; (d) probe servers `await server.wait_closed()` on stop instead of just `srv.close()` — same rapid-restart race. +- **VP diagnostic now probes both bind ports 3000 and 3002** — Pre-fix: non-proxy bind diagnostic only probed 3002. The bind server in server mode actually listens on both (plain on 3000, TLS on 3002 per `bind_server.py:BIND_PORTS`); a VP whose plain listener failed to start but TLS listener succeeded would pass the diagnostic while being half-broken. Now `port_bind` reports `pass` only when both probes succeed. New `PORT_BIND_PLAIN = 3000` constant. +- **VP FTP `stop()` awaits cancelled sessions instead of `sleep(0.1)`** — A session mid-write, mid-TLS-handshake, or holding a 60 s data-read could easily outlive the 100 ms sleep, and the server's `close()` would run while underlying sockets were still in use. Now `stop()` cancels each session task and `asyncio.gather`s them with `return_exceptions=True`. Stop is a few ms slower in the typical case; worst-case bounded by whatever asyncio takes to propagate cancellation. +- **VP child sub-services (FTP / MQTT / Bind / SSDP) expose `ready` event for accurate `is_running`** — See Added section for full description. +- **VP per-VP TLS certificate auto-regenerates when the shared CA is rotated** — Pre-fix: `ensure_certificates` only checked that the per-VP cert file existed. When the shared CA was regenerated (its expiry within 30 days), per-VP certs on disk were still signed by the OLD CA — slicers that imported the NEW CA failed handshake. The check is now a real signature verification: `ensure_certificates` loads the on-disk per-VP cert and the on-disk CA, and verifies the cert's signature against the CA's public key via `cryptography.hazmat.primitives.asymmetric.padding.PKCS1v15`. On `InvalidSignature` (rotation detected), the per-VP cert is regenerated under the current CA. **The unit-test driven a real bug** in an earlier version of this fix: comparing Subject DN was insufficient because Bambuddy's auto-generated CAs share the same Subject Name ("Virtual Printer CA"), so DN-match returned True even after rotation. 3 tests in `test_vp_certificate_rotation.py` (reuse-when-issuer-matches, regen-when-rotated, no-CA-returns-False). +- **VP `tailscale.py::get_status` now catches `asyncio.TimeoutError`** — Pre-fix: `_run_tailscale` could re-raise `TimeoutError` after killing a stuck subprocess. The `except OSError` clause in `get_status` didn't catch it, so the exception propagated all the way to the FastAPI route handler and crashed the VP management UI for any user whose host `tailscaled` was lagging. Now the except clause covers both `OSError` and `asyncio.TimeoutError`, returning a `TailscaleStatus(available=False, error=...)` either way. +- **VP `certificate.py` CA save uses correct parent directory** — Pre-fix: `_get_or_create_ca` created `self.cert_dir` (the per-VP subdirectory) before writing the CA, but the CA writes target `self.ca_key_path.parent` (the shared CA dir — potentially a different path). Latent because the manager pre-creates both directories; surfaced by the path-correctness audit. +- **VP `_extract_plate_id` logs failures at debug instead of silent** — Pre-fix: `except Exception: return None` swallowed any failure to parse `Metadata/slice_info.config` without a log. A malformed 3MF then produced a wrong-plate dispatch with no diagnostic trail. The except now logs at debug so support bundles capture the parse error. + +## [0.2.4.3] - 2026-05-24 + +### Added +- **SliceModal: "Slice all plates" toggle for multi-plate sources** — Re-slicing a multi-plate 3MF (e.g. a "parted statue" project where each plate carries a different body part) required opening the slice modal once per plate, picking the printer / process / filaments every time, and ending up with one archive per plate. The footer now has a "Slice all N plates" checkbox for multi-plate sources: tick it and the "Slice" button flips to "Slice all N plates", submitting `plate=0` instead of the picked plate index. The backend forwards this as the BS CLI's `--slice 0` "all plates" sentinel, which produces a **single output 3MF whose `Metadata/plate_N.gcode` entries cover every plate** — one slice call, one archive, every plate inside. **Filament dropdowns also adapt**: with the toggle on, they show the *union* of every plate's slot usage (a slot a plate-2 part paints with but plate 1 doesn't was previously invisible — the user could only pick filaments for the actively-viewed plate). The union is computed client-side from the existing `platesQuery.data.plates[*].filaments` payload, so no extra round-trip. The backend `SliceRequest.plate` field's range relaxed from `ge=1` to `ge=0` to admit the sentinel (the schema's docstring spells out the three semantics: `None` → default plate 1, `0` → all plates, `>= 1` → that plate). The substitute-unused-filaments pass becomes a no-op for `plate=0` (no concept of "unused" when every plate counts), which is correct — in slice-all mode every slot the project defines IS used by something. The toggle is hidden on single-plate / STL sources where it'd be meaningless. **Cross-class slice-all is handled by a per-plate loop**: BS CLI's `--arrange` is project-wide, so `--slice 0 --arrange 1` on a cross-class source consolidates every plate's objects onto a single target bed — either packing everything onto one plate or rejecting with "Some objects are located over the boundary of the heated bed" when nothing fits. When Bambuddy detects `plate=0` combined with a class crossing, it falls back to slicing each plate independently (`plate=N, arrange=true`), then merges the N single-plate 3MF outputs into one multi-plate 3MF in `merge_plate_3mfs` — overlays each plate's `Metadata/plate_N.{gcode,gcode.md5,json,png,_small.png,no_light_N.png,top_N.png,pick_N.png}` onto the first plate's base 3MF and re-assembles `Metadata/slice_info.config` to list every plate's slice block. The resulting archive's totals are the sum of each plate's print time + filament usage. New `count_plates_in_3mf` parses `model_settings.config` for `` entries to know how many plate calls to make. Cost: N × per-plate slice time; for a 5-plate Mewtwo on H2D that's ~70s wall clock vs the single-call same-class path. **Progress toast shows loop position**: each per-plate sub-slice forwards the original `progress_request_id` + callback so the toast keeps showing the sidecar's stage messages, with the snapshot augmented with `multi_plate_index` / `multi_plate_count` — the toast renders "Plate 2 of 5 • Mewtwo.gcode.3mf — Generating G-code (47%) — 23s" instead of just elapsed time. New `slice.runningWithProgressMultiPlate` i18n key translated across all 9 locales. **Per-plate cover images preserved**: BS CLI with `--arrange` regenerates plate gcodes but rarely writes a fresh `Metadata/plate_N.png`, so the merged 3MF would have only plate 1's cover. The merger now takes the source 3MF as an optional fallback and lifts the source's per-plate render (`plate_N.png` / `plate_N_small.png`) into the merged file when the sliced output is missing it — same fallback approach as the archive-card thumbnail fix. **Final test coverage**: 26 unit tests in `test_slicer_3mf_convert.py` (extract canonical model, count plates, merge with overlay / passthrough / source-thumbnail fallback / sorted plates, substitute unused-slot filaments) + 3 in `test_slicer_api.py` (arrange flag wire format on preset and bundle paths) + 9 in `test_library_slice_api.py` (guard no-op semantics, re-sliced thumbnail / bed_type lifts, **a new cross-class slice-all integration test that mocks the sidecar, asserts the backend loops per-plate with `arrange=true`, and verifies the merged archive contains `plate_1..plate_N.gcode`**) + 2 in `test_archive_service.py` (Auxiliaries thumbnail fallback) + 4 in `SliceModal.test.tsx` (slice-all toggle sends `plate=0`, toggle hidden for single-plate, plus 2 pre-existing tests for the picked-plate behaviour) + 2 new in `SliceJobTrackerContext.test.tsx` (toast prefixes "Plate X of Y" when the snapshot carries the loop fields; no prefix on plain single-plate slices). 659 backend / 42 frontend tests green; backend ruff + frontend build + i18n parity all clean. 2 new tests in `SliceModal.test.tsx` (toggle sends `plate=0` to the backend; toggle hidden for single-plate sources) plus updates to the existing plate-picker test for the new label scheme. All 9 locales translated. Frontend build clean, i18n parity green at 4983 keys × 9 locales. +- **System Health — log scanner that surfaces self-fixable issues before they become support tickets** — Complements the active Connection Diagnostic with a passive check: it scans Bambuddy's recent app log against a curated catalog of known failure signatures and reports what it finds. The catalog (`backend/app/services/log_health.py`) is a deliberate allowlist — only known-bad, actionable patterns match, so a healthy install reports nothing and noisy benign churn (the occasional MQTT reconnect after a Wi-Fi blip) is gated behind a per-signature `min_count` threshold. Six seed signatures cover the recurring "layer 8" causes from the closed-issue triage: rejected access code, FTPS :990 timeout, FTPS TLS handshake failure, flapping MQTT connection, unreachable camera (RTSPS :322), and SQLite `database is locked` contention. Each finding is deduped (`occurred N×, last seen …`), classified as *you can fix this* / *environment* / *please report this*, and carries a deep-link to the troubleshooting wiki; sample log lines are sanitized (IPs, serials, access codes redacted) before they leave the process. Exposed via `GET /system/health` and surfaced on two surfaces that share one `SystemHealthPanel` component: a System Health section on the System page (on-demand re-scan), and inline in the bug reporter when the form opens — so a setup mistake gets self-resolved instead of becoming a GitHub issue. The Add-Printer and Edit-Printer dialogs also gained a setup-time pre-flight: saving now runs the connection diagnostic and, if a check fails, warns with a "save anyway" escape hatch instead of silently saving a printer that will immediately show offline. Log-reading and redaction primitives were extracted from `routes/support.py` into a shared `backend/app/services/log_reader.py` (behaviour-preserving). 13 backend tests (`test_log_health.py`, `test_system_api.py`) and 8 frontend tests (`SystemHealthPanel`, `BugReportBubble`, `AddPrinterPreflight`, `EditPrinterPreflight`); all strings translated across the 9 locales. Backend ruff clean, full unit suite green, frontend build clean, i18n parity green. +- **Event-loop stall watchdog — makes a frozen backend self-diagnose (#1486 groundwork)** — Several "container hangs after adding a printer" reports share a signature that leaves nothing to act on: the HTTP server goes silent, `/health` hangs, the process may stop responding to SIGTERM — and the logs just stop mid-stream with no traceback, because a frozen asyncio event loop cannot log anything. New `backend/app/services/loop_watchdog.py` closes that blind spot: an async heartbeat re-arms `faulthandler.dump_traceback_later()` every 10s, always 30s ahead. While the loop ticks, the timer is cancelled and re-armed before it can fire; if the loop stalls, the heartbeat can't re-arm and faulthandler's dedicated C-level timer thread — which runs independently of the frozen loop — dumps **every thread's stack to stderr**. The blocked frame then appears in `docker compose logs`, turning an un-diagnosable freeze into a one-command capture. Started in the app lifespan after migrations, stopped cleanly on shutdown; 30s threshold is well above any legitimate on-loop operation, so a trip always means a real bug. 5 unit tests in `test_loop_watchdog.py` (arms the timer, idempotent start, stop disarms + cancels, heartbeat interval below the threshold, survives a re-arm error). Backend ruff clean; full app lifespan verified via the integration suite. +- **Slicer: process & filament profiles filtered by the selected printer (#1325, requested by @IndividualGhost1905)** — In the server-side Slice dialog, picking a printer profile now filters the Process and Filament dropdowns to presets compatible with that printer; presets that resolve to a different Bambu model drop into a trailing "Other printers" group instead of cluttering the main list. Matching uses the slicer's own `compatible_printers` list for imported (local) presets, and falls back to the `@BBL ` name suffix for cloud and standard presets, so all three tiers are covered. Compatibility-unknown presets (custom or untagged) are never hidden. Defaults follow suit — the pre-picked process and per-slot filament now prefer a printer-compatible preset, and switching the printer re-picks any selection left incompatible. The printer and process dropdowns also default to the preset names embedded in the source 3MF's `project_settings.config` when those presets are available, instead of always taking the first listed preset. New `frontend/src/utils/slicerPrinterMatch.ts` (11 unit tests) and `extract_embedded_presets_from_3mf` (5 unit tests); `UnifiedPreset` now carries `compatible_printers`, exposed for the local tier (`backend/app/api/routes/slicer_presets.py`); the plates endpoints return `embedded_printer` / `embedded_process`. Parity green, build clean. +- **Spanish (es) translation (#1243, requested by @MiguelAngelLV)** — Bambuddy now ships a full European Spanish locale. New `frontend/src/i18n/locales/es.ts` translates all 4899 keys with placeholders, plural forms, and inline markup preserved; registered in `frontend/src/i18n/index.ts` and selectable as "Español" in the language picker. The parity checker auto-discovers the file — `frontend/scripts/check-i18n-parity.mjs` gained an `ES_COGNATES` allow-list for genuine Spanish cognates and brand/format tokens. Brings the supported-language count to 9 (en / de / es / fr / it / ja / pt-BR / zh-CN / zh-TW). Parity green, frontend build clean. +- **Currency: Belize Dollars (BZD) added to the Settings → Cost currency dropdown (#1454, requested by @PLGuerraDesigns)** — Reporter accurately tracks 3D-printing filament costs in his local currency and BZD wasn't selectable, forcing a manual 2:1 mental conversion from USD. Added `BZD: 'BZ$'` to `frontend/src/utils/currency.ts` next to MXN (Americas dollar-prefix grouping); `getCurrencySymbol('BZD')` returns `'BZ$'` and the SUPPORTED_CURRENCIES list now has 30 entries. Unit test added in `frontend/src/__tests__/utils/currency.test.ts` covering the symbol lookup and presence in SUPPORTED_CURRENCIES; entry-count assertion bumped to 30 so any future additions/removals are caught immediately. 14 currency tests green; frontend build clean. +- **Connection Diagnostic — self-service triage for "printer won't connect / won't print"** — A triage review of recently-closed issues found roughly a third were user-side setup errors (printer not in LAN developer mode, blocked ports, Docker bridge networking, wrong access code, printer on a different subnet), each costing a multi-round-trip "enable debug logging → build a support bundle → upload it" exchange. A new diagnostic (`backend/app/services/printer_diagnostic.py`) runs those checks automatically: TCP reachability of MQTT 8883 / FTPS 990 / RTSPS 322, LAN developer mode, Docker network mode, printer/host subnet match, and MQTT credential class — each returning a pass / fail / warn / skip status with a localized plain-language fix. Exposed via `GET /printers/{id}/diagnostic` (saved printer) and `POST /printers/diagnostic` (pre-save Add-Printer flow), and surfaced as a one-click "Run diagnostic" from the printer card actions menu (plus a quick button on the card when a printer is offline), the Add-Printer dialog, and a new Connection Diagnostic section on the System page. The in-app bug reporter scans configured printers when the report form opens and always shows the result — a healthy confirmation when nothing's wrong, or the detected problem and its fix inline — so setup mistakes get self-resolved instead of becoming GitHub issues. The GitHub `config.yml` troubleshooting link was repointed from the wiki source repo to the rendered troubleshooting page. Backend service unit tests (15) and frontend modal tests (3) added; all diagnostic strings translated across the 8 locales. Backend ruff clean, frontend build clean, i18n parity green. + +### Changed +- **Settings → SpoolBuddy: CPU load tile added to the device card** — The SpoolBuddy daemon's heartbeat already reports `load_avg` (1/5/15 min) and `cpu_count` via `system_stats` (see `spoolbuddy/daemon/system_stats.py`), but the device card on the Bambuddy SpoolBuddy settings only rendered CPU temp / memory / disk / system uptime. Adds a fifth tile next to CPU temp showing the 1-minute load average alongside core count and a percent-of-cores readout — for a 4-core Pi: `1.20 / 4 (30%)`. Falls back to a bare load number when `cpu_count` isn't reported, and the tile is hidden entirely when the daemon doesn't emit `load_avg` (older builds). Useful for spotting the "I2C/SPI stuck after idle overnight" pattern early — sustained high load before the bus dies points at runaway daemon work rather than a kernel hang. Translated across all 9 locales (de/es/fr/it/ja/pt-BR/zh-CN/zh-TW). Frontend build clean, i18n parity green. +- **Virtual printer: setup diagnostic + one-click slicer-certificate export** — Two recurring virtual-printer support pains, addressed on the Virtual Printers settings page. **(1) Setup check** — a new stethoscope action on each VP card runs `GET /virtual-printers/{id}/diagnostic` and shows a pass/fail/warn/skip checklist: VP enabled, services running, bind interface still exists, access code set, target printer (proxy mode), and — decisively — a live TCP probe of the FTP/MQTT/discovery ports on the bind IP. The manager swallows per-service start errors (`run_with_logging`), so a service object can exist while nothing is actually listening; probing the bind IP from outside is the only reliable signal, and it catches the common "VP doesn't show up in the slicer" bind-IP-conflict and stale-interface cases. New `backend/app/services/virtual_printer/diagnostic.py` + `VPDiagnosticResult` schema + `VirtualPrinterDiagnosticModal.tsx`. **(2) Slicer certificate** — virtual printers present a TLS cert signed by a shared CA the slicer must trust; until now users had to `docker exec` in and `cat bbl_ca.crt` to get it. A new "Slicer certificate" row on the Virtual Printers settings card (alongside the Archive name source toggle) offers Copy and Download (`bambuddy-virtual-printer-ca.crt`) plus the CA's SHA-256 fingerprint, served by `GET /virtual-printers/ca-certificate` — only the public certificate, never the CA private key. The CA is generated on demand so the button works before the first VP is enabled. Copy uses a non-secure-context fallback (Bambuddy is usually on plain-HTTP LAN), extracted into a shared `utils/clipboard.ts`. 9 backend diagnostic/CA unit tests + 4 route integration tests + 6 frontend tests (diagnostic modal, clipboard helpers); all `vpDiagnostic.*` / `virtualPrinter.caCert.*` strings translated across the 9 locales. Backend ruff clean, frontend build clean, i18n parity green. +- **Bug-report panel: connection diagnostic no longer overflows on multi-printer setups** — The "Report a Bug" panel scans every configured printer on open and surfaces connection problems inline so users can self-fix before filing. The first cut rendered a full ~6-row checklist for *each* problem printer stacked vertically; a user with many printers all reporting issues pushed the description box, screenshot uploader and Submit button far below the fold in the `max-w-md` / `max-h-[80vh]` panel. The diagnostic section is now a compact summary — one line ("N of M printers have connection issues") followed by the affected printers as collapsed rows (healthy printers count toward M but render no detail). Each row expands on demand to that printer's full checklist via the shared `Collapsible` widget; when exactly one printer has problems the row is auto-expanded since that's the case where inline detail is wanted with no extra click. The panel now stays a fixed ~3 lines plus one row per affected printer regardless of fleet size, keeping the report form reachable. Healthy-fleet confirmation line is unchanged. New `bugReport.diagnosticSummary` key (with `{{problems}}`/`{{total}}`) replaces the static `diagnosticHeading`; `diagnosticIntro` reworded to be printer-count-neutral and point at the expand affordance — both translated across all 9 locales. 2 new tests in `BugReportBubble.test.tsx` (multiple problems stay collapsed and expand on click; a single problem auto-expands); 11 tests green; frontend build clean; i18n parity holds at 4903 keys × 9 locales. +- **Color Catalog sync now identifies itself as Bambuddy to filamentcolors.xyz** — The FilamentColors.xyz sync client in `inventory.py` created its `httpx.AsyncClient` with no `User-Agent`, so it leaked httpx's default `python-httpx/x.y` string — the only outbound client that did (`bambu_cloud`, `makerworld`, `firmware_check` all send the honest `Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)`). It now sends the same honest UA, consistent with the rest of the codebase. Surfaced while investigating #1456 (a Cloudflare `403` on the sync that turned out to be the reporter's network/IP reputation, not Bambuddy — the UA leak was a separate inconsistency found in passing, and this change does not by itself resolve a Cloudflare IP block). +- **Filament inventory: grouped rows now show group totals (#1368, requested by a user)** — With "Group similar" enabled, the collapsed group row showed the values of a single member (the first spool) — so a group of five 1 kg spools displayed "1000 g" instead of the 5 kg it actually held. The group header now aggregates across all members: the table view's Label, Net, Gross, Used and Remaining columns and the grid card's weight figure show group totals, while identity columns (Material, Brand, Colour) and the Cost/kg rate stay per-spool-correct. Per-spool-only fields with no meaningful total (dates, location, note, tag ID) keep showing the representative member's value; the expanded individual rows are unchanged. New `aggregateGroupSpool` helper in `frontend/src/utils/inventoryGrouping.ts` with 4 unit tests. Frontend-only — all data was already in the spool list. — Previous behaviour disabled the Slice button whenever the source 3MF's bound printer model didn't match the user's picked printer profile, on the theory that the slicer CLI "cannot re-slice a 3MF for a different printer" and would silently fall back to embedded settings to produce a wrong-printer file. Step 0 empirical test on 2026-05-20 disproved that: an 18-color H2D-bound `Trent900.3mf` sliced via the X1C bundle (`POST /slice` with `bundle=cb…X1C, printerName=# Bambu Lab X1 Carbon 0.4 nozzle`) produced 2.3 MB of genuinely X1C-compatible G-code in 1.8 s — `printer_model` overridden to `Bambu Lab X1 Carbon`, `printable_area` to 256×256 (X1C bed, not H2D's 350×320), `printable_height` 250 (vs 325), `bed_exclude_area` populated with X1C's 18×28 corner zone, `nozzle_diameter` single 0.4 (vs H2D's dual `0.4,0.4`), and the full X1C `machine_start_gcode` sequence baked in. The sidecar takes printer / process / first-N filament names from the picked bundle and only inherits embedded values for unused trailing slots — bed size, kinematics, start sequence all come from the target. **Behavioural change**: dropped `!printerMismatch` from the SliceModal `isReady` predicate so the Slice button stays enabled when models differ. The amber banner was first softened to an info message, then removed entirely — re-slicing across printers is now just a normal slice, the picker UI already shows which printer was picked, no second confirmation needed. **Dead-code removal (same drop)**: with no banner, the `source_printer_model` field on the `/library/files/{id}/plates` and `/archives/{id}/plates` responses had zero consumers; the `extract_source_printer_model_from_3mf` helper in `threemf_tools.py` (which opened the 3MF zip and read `Metadata/project_settings.config` on every plate request) had zero callers. Removed both response keys, both backend extractions, both `threemf_tools` imports, the helper itself, its 6 unit tests, the `source_printer_model` field from `frontend/src/types/plates.ts` (PlateMetadata + LibraryFilePlatesResponse), and 2 obsolete SliceModal tests that exercised the now-impossible matched-printer / legacy-archive paths. **i18n discipline cleanup (same drop, per [[feedback_no_followups]] + [[feedback_translate_dont_fallback]])**: every t() callsite in SliceModal.tsx had an inline English `defaultValue:` or positional-second-arg English fallback — 22 sites in total. With 8 locales shipped, those fallbacks are dead weight at best, and an actual i18n-violation when the key is missing because non-English users would silently see English. Audit found 3 keys (`slice.bundle`, `slice.bundleNone`, `slice.bundleAllRequired`) that had **no** corresponding entry in any locale file — they were being served from the inline English fallback exclusively, meaning every non-English user was already seeing those three labels in English. Added all 3 to all 8 locales with real translations, then stripped the English fallback from every t() call in SliceModal.tsx. The `slice.printerMismatch` key was removed from all 8 locales (banner is gone). **Why this matters**: a recurring pain point for users importing MakerWorld project files where the original creator's printer often differs from the user's; previously they had to round-trip through BambuStudio's "convert project" flow to re-export. Now Bambuddy re-slices in-place with no UI friction. **Tests**: the existing SliceModal "shows mismatch warning AND disables Slice" test was rewritten to assert "does not surface any cross-printer banner AND keeps Slice enabled when models differ" (regression guard against the gate being re-added); 2 obsolete tests deleted. 32 SliceModal tests green (was 34, -2 dead tests); 49 threemf_tools tests green (was 55, -6 helper tests); 24 plates-route tests green; frontend build clean; backend ruff clean; i18n parity check passes 4858 keys × 8 locales (net +2 vs pre-fix: +3 bundle keys, -1 printerMismatch). + +### Security +- **idna: bump to `>=3.15` to clear CVE-2026-45409 (ReDoS in `idna.encode()` with crafted Unicode payloads, e.g. `"٠" * N` or `"・" * N + "漢"`)** — Transitive dep pulled in by anyio / httpx / requests / yarl; not directly pinned, which is why it lingered at 3.13. Added an explicit `idna>=3.15` floor in `requirements.txt` between Authentication and HTTP-client blocks with a comment explaining why it's pinned (so a future downstream loosening doesn't silently downgrade us). Verified via `pip-audit` clean post-upgrade. +- **starlette: bump floor to `>=1.0.1` to clear PYSEC-2026-161** — `starlette` is a transitive dep pulled in by fastapi, whose range still admits the vulnerable 1.0.0 build, so a fresh `pip install` would silently pick it back up. Added an explicit `starlette>=1.0.1` floor in `requirements.txt` under the urllib3 pin with a why-comment matching the same pattern as the idna/urllib3 entries. Release-notes reviewed for both 1.0.1 (single fix: ignore malformed `Host` header when constructing `request.url`) and 1.1.0 (the resolver actually picked up 1.1.0): three behavioural changes — `FileResponse` falls back to `application/octet-stream` when `mimetypes.guess_type()` can't resolve (Bambuddy has 2 `FileResponse` calls without explicit `media_type`, both serving `index.html` where guess_type still resolves to `text/html`, plus custom-icon serving in `external_links.py:261` where the new fallback is a security improvement), `HTTPEndpoint` only dispatches standard HTTP verbs (`grep` found zero `HTTPEndpoint` usages in Bambuddy — pure FastAPI router code), `StaticFiles.lookup_path` rejects absolute paths in *requests* (the 4 mounts in `main.py:5503-5525` pass absolute *base directories* to the constructor, which is unaffected — only path-traversal-style request paths get rejected). Full backend test suite green (5300/5301; the 1 failure is a pre-existing `-n 30` parallelism flake unrelated to starlette and passes in isolation). Verified clean via `pip-audit` post-upgrade. +- **PyJWT CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): permanently ignored in pip-audit** — Advisory is disputed by the PyJWT maintainers, with the advisory description literally noting *"this is disputed by the Supplier because the key length is chosen by the application that uses the library."* `fix_versions=[]` on the advisory confirms no PyJWT patch exists or will exist. Bambuddy is not affected: `backend/app/core/auth.py:184` auto-generates secrets via `secrets.token_urlsafe(64)` (~86 chars of entropy, far above any sane minimum) and the file-loaded path at `:177` rejects secrets shorter than 32 chars. Added a permanent `--ignore-vuln CVE-2025-45768` to `.github/workflows/security.yml` with an inline comment citing the file:line evidence so a future maintainer reviewing the ignore list sees why it's load-bearing. Also dropped the stale `--ignore-vuln CVE-2026-4539` for Pygments — Pygments has since shipped a patched version and the ignore is no longer load-bearing (verified: `pip-audit --ignore-vuln CVE-2025-45768` alone reports clean). + +### Fixed - **Support bundle + bug-report submission now include the live diagnostic snapshot** — Three diagnostics (Connection Diagnostic per printer, Virtual Printer Setup Diagnostic per enabled VP, Log Health Scanner) have shipped on the System page and inline in the bug-report bubble since 6bc6a1d6 / e222a0ef / ed31b8f4, but the results were only ever shown to the *user* — never persisted into the downloadable support ZIP or the submitted GitHub issue. A report saying "looks broken in Bambuddy" arrived with no actionable signal beyond raw logs. **Fix**: new `services/diagnostic_snapshot.collect_diagnostic_snapshot` runs all three concurrently with an outer per-probe 15 s wall-clock cap (so a hung interface adds at most ~15 s to bundle generation regardless of fleet size — `asyncio.gather`, total ≈ max(per-cap) not sum). Fail-soft per probe: a crash inside one printer's check emits `{"printer_id": N, "error": "..."}` for that entry rather than nuking the whole snapshot — partial result beats a 500. Wired into `_collect_support_info()` so both flows (`POST /support/bundle` and `POST /bug-report/submit` via `support_info=...`) pick up the new `diagnostics` top-level key without their own changes. **Private-data sanitization** — the diagnostic schemas embed raw IPv4 in three places (`PrinterDiagnosticResult.ip_address`, network-mode check's `params.{printer_ip, host_ip}`, VP diagnostic's `params.bind_ip`), and the snapshot adds printer names. None of those should leak. The snapshot now runs a recursive sanitizer on the full result tree before returning: known DB-listed values (printer name, IP, serial, access code) get the same `[PRINTER]/[IP]/[SERIAL]/[ACCESS_CODE]` labels the log sanitizer already applies (via the shared `collect_sensitive_strings`), and an IPv4-regex fallback catches IPs the DB doesn't know about — most importantly the Bambuddy host IP returned by `_get_host_ip()` and any VP `bind_ip` the user picked at setup. Live-DB smoke test confirms zero raw IPv4 instances in the serialized snapshot output. **Progress indicators**: the bubble's "submitting" view and the System page's Download button now render a static four-line checklist showing what's running (printer connectivity → VP setup → log scan → submit/build ZIP) — communicates the longer wait honestly without faking server-side phase progress we can't actually track. **Tests**: 6 new in `test_diagnostic_snapshot.py` — empty-input shape stable, per-printer / per-VP result coverage, fail-soft on a single-probe crash, `timed_out` marker when a probe exceeds the per-probe cap (test patches the cap to 0.05 s), end-to-end IP sanitization across all five field shapes (top-level `ip_address`, `printer_ip`, `host_ip`, `bind_ip`, plus IPs embedded in log-health sample lines) with a final regex sweep over the JSON-serialized result asserting zero raw IPv4 escapes, concurrent execution proof (4 × 0.2 s probes complete in < 0.5 s, would be 0.8 s sequential). Existing 27 BugReportBubble + SystemInfoPage frontend tests still pass; 9-locale i18n parity check clean (4993 leaves per locale, 9 new keys added with real translations everywhere — no English fallback). Backend ruff clean. - **"Prefer Lowest Remaining Filament" now uses Bambuddy's inventory weight, not just the printer's RFID counter (#1508, reported by @kleinwareio)** — Reporter has an inventory spool cloned to slot 1 and the original (much further used) in slot 4 of the same P1S AMS, with the preference enabled, and the dispatch consistently picked slot 1 (the fresh clone) instead of slot 4 (the original they wanted to finish first). Root cause is the `prefer_lowest` sort in `_match_filaments_to_slots` (`print_scheduler.py`): the sort key reads `f.get("remain", -1)` straight out of `_build_loaded_filaments`, which sources it from MQTT AMS `tray.remain` — the printer firmware's own RFID-decremented value. Two problems with that signal: (a) it's only populated for Bambu RFID spools, so every non-RFID / 3rd-party / user-loaded tray reports `-1` and gets clamped to a sentinel — multiple non-RFID spools then tie in the sort and Python's stable sort collapses to AMS-slot insertion order, so slot 1 always wins; (b) even when set, it's the *printer's* counter, not Bambuddy's `label_weight - weight_used` (internal mode) or Spoolman's `remaining_weight` (Spoolman mode) — the two diverge any time the user re-spools, swaps cardboard, or runs a print outside Bambuddy. The reporter is on internal-inventory mode with non-RFID spools — both failure modes apply, hence slot 1 every time. **Fix**: when a slot is bound to a Bambuddy / Spoolman spool, that inventory record's remaining weight becomes the sort signal. New async helper `_build_inventory_remain_overrides(db, printer_id, loaded)` returns `{global_tray_id: remaining_grams}` for slots with an assignment — internal mode joins `SpoolAssignment` → `Spool` once per dispatch, Spoolman mode joins `SpoolmanSlotAssignment` then fetches each spool through the existing `_spoolman_remaining_grams` (shared with `filament_deficit.py`, parity rule per [[feedback_inventory_modes_parity]]). The new `_prefer_lowest_sort_key` consumes that map alongside the legacy MQTT field with a **two-tier** comparison: inventory-tracked spools always sort BEFORE MQTT-only spools, then ascending by remaining within each tier, then ascending by `ams_id * 4 + tray_id` as the deterministic slot tie-breaker. The tier flag dominates so we never compare grams (inventory) against percent (MQTT) — no unit-conversion contortions. MQTT-only behaviour is preserved exactly: `remain = -1` still maps to the 101 sentinel and slot order still decides on ties, so users who haven't bound any spools see no change. External / VT tray slots are skipped (tracked separately from AMS bindings). Lookup runs only when `prefer_lowest_filament` is enabled — no extra DB hit for users who don't use the preference. **Tests**: 6 new in `TestPreferLowestInventoryOverride` in `test_scheduler_ams_mapping.py` (inventory override beats MQTT remain — the literal reporter scenario with 950 g clone vs 50 g original; zero-grams still sorts first within its tier; inventory tier beats MQTT tier regardless of value; tied inventory grams break to lower slot; no-override falls through to MQTT — regression guard for un-tracked spools; legacy `remain = -1` still sentinel-sorts last when override map is None) + 7 new in `test_scheduler_inventory_remain.py` covering `_build_inventory_remain_overrides` directly (internal mode returns label_weight − weight_used per bound slot; external slots skipped; empty loaded short-circuits; over-consumed spool clamps to 0 g; unbound slots absent from map; Spoolman mode uses `_spoolman_remaining_grams` for parity; Spoolman unreachability silently omits that slot). 102 scheduler + inventory tests green; backend ruff clean. - **X1/H2/P2 live camera no longer fails with `Address already in use` on transitional ffmpeg builds (#1504, reported by @rage03usa, confirmed by @PawseHaxor)** — On a native Ubuntu install with the Jammy-era system ffmpeg, the RTSP live-view path retried indefinitely with `Unable to open RTSP for listening … Address already in use`. Snapshots, the camera diagnostic, and OrcaSlicer all kept working — only live view was broken. Cause: the ffmpeg argv built in `backend/app/api/routes/camera.py` (added in 530a7a46 as part of an RTSP-stability bundle) passed `-timeout 30000000`. That ffmpeg version *deprecated* the original `-timeout` (socket I/O microseconds) and repurposed the name to mean the *RTSP listen-mode incoming-connection timeout* — any non-zero value **implies `-listen`**. ffmpeg then flipped into RTSP server mode and tried to bind the same localhost port Bambuddy's TLS proxy was already listening on, hence EADDRINUSE on every retry (the odd-port pattern @PawseHaxor noticed is coincidence — the ephemeral allocator just picked odd values that run). The reporter's own workaround (drop the option) works but silently loses the socket-level read timeout, so a hung TLS handshake would block past the OS TCP timeout instead of failing fast into the existing reconnect loop. **Why this can't be a one-line literal swap**: ffmpeg has shipped *three* arrangements of this option over time and Bambuddy supports the full range. Pre-deprecation builds: `-timeout` is the socket I/O timeout. Transitional builds (~late-4.x, what the reporter is on): `-timeout` is the broken listen-mode option, `-stimeout` is the replacement. **Modern ffmpeg (5.x / 6.x / 7.x — current Debian 13, Ubuntu 24.04, current Homebrew)**: `-stimeout` was removed entirely and `-timeout` is back to socket I/O. So both literals regress one half of the install base. **Fix**: a new `rtsp_socket_timeout_flag()` helper in `backend/app/services/camera.py` probes `ffmpeg -h demuxer=rtsp` once at first use and picks `-stimeout` when ffmpeg advertises it (transitional window) or `-timeout` otherwise (modern + very old). The result is cached for the process lifetime — ffmpeg won't swap mid-run. The function returns the option name without a leading dash so callers prepend it themselves (no empty-flag formatting bug). Wired into both RTSP ffmpeg call sites — `routes/camera.py` (printer camera) and `services/external_camera.py` (external RTSP) — in lockstep, same TLS-proxy + ffmpeg pattern, same regression. The reporter had tried `-listen_timeout` (doesn't help — we *don't* want listen mode) and `-rw_timeout` (AVIO-level, RTSP demuxer doesn't honour it on its control socket), but no manual swap could be correct for both transitional and modern installs simultaneously. **Tests**: 8 in `test_ffmpeg_rtsp_timeout_flag.py` — 6 unit tests for the probe (picks `-stimeout` when advertised, falls back to `-timeout` on modern, defaults to `-timeout` when ffmpeg missing or probe raises, caches across calls, substring-match guard against false-positives on `-listen_timeout`), 2 parametrised regression guards against either RTSP ffmpeg argv re-hard-coding a literal flag instead of consuming the probe. 37 (probe + existing external-camera) tests green; backend ruff clean. @@ -105,6 +147,92 @@ All notable changes to Bambuddy will be documented in this file. - **Archives: timelapse auto-attach now works for VP-queue / dispatch prints (#1403 follow-up, reported by @pwostran)** — Bambuddy uses a snapshot-diff strategy to pick the right MP4 off the printer's SD card after a print (Bambu printers in LAN-only mode don't sync NTP, so file mtimes are unreliable — `_scan_for_timelapse_with_retries` snapshots existing video filenames at print start and looks for any NEW filename at completion). The baseline-capture call was inline at the bottom of `on_print_start`'s **new-archive branch only** — the **expected-archive branch** (which queue / VP-dispatched / reprinted jobs take, anything registered via `register_expected_print`) exited at its own `return` without ever snapshotting. So queue prints had `_timelapse_baselines[printer_id]` unset; the completion-time scan fell into its "take baseline now" fallback that snapshots the SD card *after* the new MP4 has already landed → the new file sits in the "baseline" set → no diff ever matches → auto-attach silently does nothing. The reporter's `bambuddy-support-20260518-185935.zip` shows the failure verbatim: `Using expected archive 3 for print (skipping duplicate)` at `18:41:10`, `Timelapse was active during print, scheduling auto-scan for archive 3` at `18:58:55`, and `[TIMELAPSE] Archive 3 has no printer, aborting` (a separate bug already fixed by the printer_id-assignment commit in this same train) — and `grep -i baseline` across both his bundles returns zero hits, confirming the snapshot never ran. Fix extracts the inline baseline-capture into `_capture_timelapse_baseline_at_start(printer, printer_id, logger)` and calls it from BOTH branches of `on_print_start` (mirroring the existing site in the new-archive branch with a matching call just before the expected-archive branch's `return`). Helper is best-effort with a `try / except Exception` wrapping `_list_timelapse_videos`, so a transient FTP failure at print-start logs `[TIMELAPSE] Failed to capture baseline at print start: …` and the print proceeds — the completion-time fallback still kicks in (with its known limitation), behaviour matching what the new-archive branch had all along. **Tests:** new `test_expected_archive_path_captures_timelapse_baseline` in the existing `test_print_start_assigns_printer_id_to_vp_archive.py` patches `_list_timelapse_videos` to return two pre-existing videos, runs `on_print_start` through the expected-archive branch, and asserts `_timelapse_baselines[1] == {"earlier_print_a.mp4", "earlier_print_b.mp4"}` — a future refactor that removes the call from one of the branches now fails CI. The existing 2 regressions (`test_expected_archive_path_assigns_printer_id_when_unset`, `test_expected_archive_path_preserves_existing_printer_id`) plus 50 adjacent expected-archive / layer-timelapse / archive-filtering tests still green. The fixture clearing `_expected_prints` etc also clears `_timelapse_baselines` now so test isolation holds. +## [0.2.4.2] - 2026-05-19 + +### Added +- **Docker: opt-in system trust store for self-signed CA certificates (#1431, contributed by @WizBangCrash, requested in #1289)** — Reporter runs a private LAN with self-signed certificates for internal HTTPS endpoints (his Home Assistant instance being the canonical case) and wanted Bambuddy to trust those CAs without disabling TLS verification end-to-end. Bambuddy talks to Home Assistant via `httpx.AsyncClient` (`backend/app/services/homeassistant.py:46`) with default `verify=True`, which under httpx 0.28 means "use `certifi`'s CA bundle and nothing else" — so manually copying a CA file into the container had no effect. **The fix is opt-in and container-side only**: setting `USE_SYSTEM_TRUST_STORE=` in the compose `environment:` block, combined with mounting the user's CA file(s) into `/usr/local/share/ca-certificates`, makes the entrypoint run `update-ca-certificates --fresh` at startup and `export SSL_CERT_DIR=/etc/ssl/certs`. httpx 0.28 explicitly honours that env var (`_config.py`: `ssl.create_default_context(capath=os.environ["SSL_CERT_DIR"])`), and `update-ca-certificates` populates `/etc/ssl/certs` with the **Debian system CA bundle** (Let's Encrypt, DigiCert, GlobalSign, etc.) **plus** the user-mounted CAs — so standard endpoints (api.github.com, MakerWorld, Bambu Cloud) keep working alongside the user's self-signed CA. The `ca-certificates` apt package is added to the Dockerfile so `update-ca-certificates` exists in the image. The feature is **default-off** — when the env var is unset the entrypoint logs a one-line "skipping system trust store update" and goes straight to the existing PUID/PGID chown path, so non-users see zero behaviour change. **Fail-fast on misconfig**: if `USE_SYSTEM_TRUST_STORE` is set but the container is running as non-root (the entrypoint can't write `/etc/ssl/certs` without root), or `/usr/local/share/ca-certificates` has no `.crt` files mounted, or `update-ca-certificates` is missing from the image, or the trust-store rebuild itself fails, the entrypoint exits 1 with a clear error message rather than silently succeeding and leaving the user wondering why their HA connection still rejects the cert. **Compose template update**: `docker-compose.yml` ships commented-out examples for both the volume mount (`/path/to/certs:/usr/local/share/ca-certificates`) and the env var (`USE_SYSTEM_TRUST_STORE=true`) so the path from "I have a self-signed CA" to "Bambuddy trusts it" is two uncommented lines. **Caveat worth flagging in docs**: the feature requires the container to start as root so the entrypoint can run `update-ca-certificates`; users who pin `user: "1000:1000"` in compose get the clear "not running as root" exit with the reason, but they need to switch to the default PUID/PGID-style invocation to use this. Companion wiki PR documents the setup walkthrough at maziggy/bambuddy-wiki#31. Hardware-only path (shell entrypoint change) so no automated test — verified by the reporter's local install. Post-merge polish: the fatal-exit branch's log line was relabeled from "warning: update-ca-certificates failed:" to "error: update-ca-certificates failed" to match severity and the surrounding error messages. + +- **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. + +### Changed +- **Inventory: AMS Filament Label Holder presets fixed and split into "small" and "large" variants (#1426, reported by @bsaunder)** — Reporter (the same person who originally requested the labels feature in #809) discovered that the `ams_30x15` preset's 30×15 mm dimension didn't fit any documented variant of MakerWorld model 752566, despite the preset advertising itself as designed for it. Two new presets replace it: `ams_holder_74x33` (matches the printable label STL bundled in the project) and `ams_holder_75x55` (fits the cardstock-insert variant the reporter validated as "fits perfectly"). Both land in the roomy-layout branch (swatch + QR + multi-line text with brand / material / hex / spool ID) because the height crosses the 20 mm threshold — so the larger AMS holder labels carry the QR code back to `/inventory?spool=` that the old 30×15 preset couldn't fit. The 30×15 preset is removed entirely; no DB migration needed because the preset name was never persisted (label printing is a one-shot action and the picker defaults to nothing). The legacy tight-layout code path in `_draw_label_tight` is kept as the safety branch for any future ultra-small preset (no shipped template uses it now). **API change**: `POST /inventory/labels` and `POST /spoolman/labels` accept `template` values `ams_holder_74x33` and `ams_holder_75x55` instead of `ams_30x15`. The Literal types in `backend/app/api/routes/labels.py` and `frontend/src/api/client.ts::SpoolLabelTemplate` reject the old value at validation time, so any caller still scripting `ams_30x15` gets a 422 with a clear "valid values" message. i18n: replaced `inventory.labels.templates.ams.{label,hint}` with `inventory.labels.templates.amsHolderSmall.{label,hint}` and `inventory.labels.templates.amsHolderLarge.{label,hint}` — real translations across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW), the English-fallback strings on the old key are gone (`feedback_translate_dont_fallback` rule). Parity check passes at 4856 leaves per locale. Tests: `test_label_renderer.py::ALL_TEMPLATES` updated and the AMS-specific render test pins the small variant; `test_labels.py` integration test covers both new presets end-to-end; `LabelTemplatePickerModal.test.tsx` updated to expect 6 template buttons in the grid (was 5), pin the new `ams_holder_75x55` template value in the API call assertion, and verify both AMS variants are disabled when no spools are selected. 35 affected backend tests + 17 frontend modal tests green; full suite 5035 green; ruff clean; frontend build clean. + +### Fixed +- **Archives: "Scan for timelapse" no longer permanently disabled on VP-queue-dispatched prints (#1403 follow-up, reported by @pwostran and @enjoylifenow)** — Reporters dispatched a print from a slicer via the VP print queue, the printer recorded the timelapse to its SD card (visible and downloadable via Bambuddy's file browser), but the archive UI's "Scan for timelapse" and "View Timelapse" actions stayed greyed out forever. Frontend gates both on `archive.printer_id` (`ArchivesPage.tsx:459`); backend `/archives/{id}/timelapse/scan` also 400s when the archive has no `printer_id`. Root cause was in `main.py::on_print_start`'s expected-archive branch: VP-queue archives are created with `printer_id=None` at queue-add time (we don't yet know which printer will run the job — the scheduler decides later for "Any P1S"-style queue items, and even for explicit-printer queue items the archive is created before dispatch). When the print actually starts and `on_print_start` looks up the expected archive via `_expected_prints`, the branch updated `status`, `started_at`, and `subtask_id` but never assigned `printer_id`. So the archive stayed `printer_id=None` for the entire print and forever after — and every downstream UI / API path gated on it (timelapse scan + view, the printer filter on Archives, per-printer stats, success-rate cohort attribution) treated the archive as "unassigned." One-line fix in the expected-archive branch sets `archive.printer_id = printer_id` when they differ, guarded against clobbering an already-correct value so library-file-based queue items (which create their archive with the printer pre-assigned at dispatch time) are unaffected. Two regressions in a new `test_print_start_assigns_printer_id_to_vp_archive.py`: `test_expected_archive_path_assigns_printer_id_when_unset` (VP-queue archive with `printer_id=None` → promoted to the running printer) and `test_expected_archive_path_preserves_existing_printer_id` (library-file archive with `printer_id=7` stays at 7 when the same printer runs it — the branch is idempotent on correct data). The 2 existing `test_layer_timelapse_expected_archive.py` tests + 19 `test_print_start_expected_promotion.py` regressions still green — the expected-archive branch's other side-effects (timelapse session, AMS mapping, status/started_at promotion) are unchanged. 5033 backend tests + ruff clean. **Note about the queue-mode slicer-inheritance path itself**: pwostran's specific complaint that the original #1403 fix "didn't work" was a misdiagnosis on his end — his support bundle proves Bambuddy correctly sent `timelapse: true` to the printer and the printer recorded the video; his actual gap was this archive-attachment bug. The slicer-inheritance branch added in #1403 is a no-op for OrcaSlicer's "Send to print queue" flow because Orca only does the FTP upload and never sends a `project_file` MQTT command at upload time — so the path falls back to `default_*` settings, which is the operative path for that workflow. Users who want a different per-print value still edit the queue item before starting (as pwostran did, which is why his dispatch carried `timelapse: true`). + +- **SpoolBuddy: NFC reader works again on Raspberry Pi 5 (#1424, reported by @flom89)** — Reporter on a Pi 5 installed SpoolBuddy successfully but couldn't talk to the PN5180 NFC module (the gauge worked, so SPI hardware and wiring were fine). Manually commenting out `self._spi.no_cs = True` in the daemon restored communication; reporter wasn't sure whether removing it would regress Pi 4 installs. Root cause: Pi 5 uses the new RP1 southbridge and its kernel SPI driver (`spi-rp1`) doesn't accept the `SPI_NO_CS` ioctl the same way the historical Broadcom driver on Pi 4 did — setting `no_cs = True` on Pi 5 either errors out or silently leaves the bus in a state where transfers don't complete. **Safe to drop Pi-wide, not just Pi 5** — SpoolBuddy's PN5180 NSS line is wired to GPIO23 (manual chip-select handled by `_cs_low()` / `_cs_high()` around every transfer, because the kernel's default 5µs setup / 100µs hold timing doesn't meet the PN5180's spec). The hardware CE0 line (GPIO8) is not connected to the reader, so whether the kernel auto-toggles it during `xfer2()` is electrically invisible to the PN5180. The `no_cs = True` call was a "be polite to the bus" gesture that was always cosmetic on this hardware. Fix wraps the assignment in `try / except OSError` in both `spoolbuddy/daemon/pn5180.py` (logs at debug level) and `spoolbuddy/scripts/read_tag.py` (silent — it's a diagnostic script with no logger). Try/except over a hard delete because Pi 4 installs that work today shouldn't see any behaviour change. README updated at `spoolbuddy/README.md:23-28` to drop the "spidev.no_cs = True resolves this" sentence in favour of explaining that manual CS via GPIO23 carries the timing on its own and that Pi 4 + Pi 5 are both supported. Hardware-only path so no automated test — verified by the reporter's bench test that commenting the line out restores reads. Ruff clean. + +- **Cover thumbnails: stop hammering FTP and GitHub when a print's 3MF isn't on the printer (#1420, reported by reporter)** — Reporter on a P2S running 0.2.4.1 saw two log-flooding bugs trigger together once they started a print whose 3MF wasn't on the printer's FTP storage (typical SD-card-only print). **(1) Cover endpoint had no negative cache.** `GET /printers/{id}/cover` cached successful 3MF thumbnail downloads in `_cover_cache` keyed by `(subtask_name, view_key)`, but never recorded failures. When all 8 candidate FTP paths returned `550 Failed to open file`, the endpoint raised 404 without remembering that it just tried — and since `cover_url` stays populated on every `PrinterStatus` response while state is RUNNING/PAUSE, every React-Query refetch and every component remount drove the frontend to re-fetch, replaying the same 8-path FTP fan-out roughly every few seconds. On the user's hardware the printer's single FTP socket was so busy with these doomed retries that it surfaced as camera-stream symptoms ("ffmpeg didn't terminate gracefully"). Fix adds a parallel `_cover_404_cache: dict[int, set[tuple[str, str]]]` that records the same `(subtask_name, view_key)` key on every 404 path — both the all-FTP-paths-failed branch and the 3MF-has-no-thumbnail-inside branch. On the next call for the same key, the endpoint short-circuits to 404 before even consulting FTP. The negative cache is cleared in `clear_cover_cache()` alongside the positive cache, which `main.py::on_print_start` already calls — so when the next print starts (different subtask, or same subtask after a re-upload of a new file) Bambuddy retries fresh. **(2) GitHub update-check had no backoff on 403 rate-limit.** Once `api.github.com` returned `403 rate limit exceeded` (typical when multiple Bambuddy instances or other tools share a NAT'd source IP and exhaust the unauthenticated 60-req/hr quota), the next call hit GitHub again immediately. Fix adds module-level `_github_rate_limit_until` epoch-seconds plus three helpers in `updates.py`: `_seconds_until_github_unblocked()`, `_record_github_rate_limit(response)` (reads `X-RateLimit-Reset` from the 403, falls back to a 1-hour pause when the header is absent or unparseable, and only extends the window — never shortens it via an out-of-order response), and `_is_github_rate_limit_response(response)` (status 403/429 with `X-RateLimit-Remaining: 0`, body-text fallback when proxies strip the header). Both call sites — `GET /updates/check` and the in-app updater's `_discover_target_release` — short-circuit when the window is active; the route surfaces a structured `{error: "GitHub rate limit reached...", retry_after_seconds: }` response so the SettingsPage UI can show a real wait time instead of an opaque "failed to check for updates". The "ffmpeg didn't terminate gracefully" warning line the reporter quoted is the standard SIGTERM → 2s wait → SIGKILL pattern in `camera.py::_terminate_ffmpeg` — RTSP/TLS streams routinely take >2s to drain and that warning fires for many users with no FTP issues; once the cover loop is silenced the resource pressure is gone, and the warning itself is cosmetic. **Tests**: `test_cover_negative_cache_skips_repeat_ftp_fanout` in `test_printers_api.py` mocks `download_file_try_paths_async` to return False, calls the endpoint twice, and asserts the second call's FTP mock count is unchanged (the negative cache held); `test_check_backs_off_after_github_rate_limit` in `test_updates_api.py` patches `httpx.AsyncClient` to return a 403 with `X-RateLimit-Reset` set 10 minutes ahead and asserts the second `/updates/check` request never reaches httpx and surfaces `retry_after_seconds > 0`. 134 printers + updates integration tests green; ruff clean. + +- **Assign Spool: printer card refreshes immediately, no Force-refresh needed (#1414 follow-up, reported by @snozzlebert)** — After assigning a spool via the modal, the Filament page Location column updated correctly but the Printer card kept showing "Empty slot (External Slot 1)" until the user manually pressed Force-refresh. The MQTT command itself was going through fine; the gap was on the client side. `AssignSpoolModal`'s two `useMutation.onSuccess` callbacks invalidated the inventory / slot-assignment queries (Filament page reads from those — correct) but never invalidated `['printerStatus', printerId]` and never issued a `pushall` to make the printer republish its state. For Bambu RFID-tagged spools the printer echoes the new `tray_type` over MQTT on its own and the websocket push would eventually surface it, but for non-RFID spools and A1 mini external slots (reporter's case) the firmware doesn't volunteer that state change, so the card sat on stale `tray_type: ""` and `getEmptySlotKind()` rendered the "Empty slot" path. Fix adds a `nudgePrinterRepublish()` helper called from both `onSuccess` paths (internal-inventory `assignMutation` and `assignSpoolmanMutation`): calls `api.refreshPrinterStatus(printerId)` to issue the pushall (same call the Force-refresh button uses at `PrintersPage.tsx:1844`) and invalidates `['printerStatus', printerId]` so the refetch lands. Failures from `refreshPrinterStatus` are deliberately swallowed — the assignment itself already succeeded, and if the refresh nudge is offline the next regular poll / websocket update will catch up; we don't want to surface a misleading "assign failed" toast for a stale-cache cleanup that didn't go through. Mirrors the pattern `ConfigureAmsSlotModal` has used since #1235 (line 5346) but with the extra pushall step because assign-spool affects firmware-side state where configure-slot affects only client-side preset mapping. Same fix covers both inventory modes thanks to the shared helper. **Tests:** new `nudges the printer to republish after successful assignment (#1414)` in `AssignSpoolModal.test.tsx` — picks a material-matching spool to bypass the mismatch-confirm dialog, clicks the Polymaker spool card to select it, clicks "Assign Spool", asserts `api.refreshPrinterStatus(7)` was called. `printerId=7` (not the default 1) verifies the helper threads the prop value through correctly rather than hardcoding. The api mock at the top of the file gains `assignSpoolmanSlot`, `getSpoolmanSlotAssignments`, and `refreshPrinterStatus` so the existing 13 tests still pass alongside the new one. 14 modal tests + build clean. + +- **FTP: tolerate transient 426 from buggy printer FTP when the file is actually on the SD card (#1417 follow-up, reported by @enjoylifenow)** — In the previous daily build, commit `1fac0276` tightened the post-STOR confirmation handler in `bambu_ftp.py` so that any `ftplib.Error` from `voidresp()` (including `error_temp 426` "Failure reading network stream") would fail the upload outright. The goal was to stop Bambuddy from sending a print command for a truncated 3MF when the printer's FTP server explicitly told us the data stream was cut — exactly the scenario surfacing the user's earlier "unable to parse 3mf file" 30 s into a print. Reporter then confirmed (after running a filesystem check + reformat + power cycle, all clean) that the same install **worked fine on v0.2.4.1** — proving that for the specific P2S firmware revision in question, the 426 is *noise*: the TLS data-channel close races the 226 confirmation, the server reports failure on voidresp, but the file *did* land fully on the SD card. The previous proceed-with-warning behaviour was accidentally correct for that firmware quirk. Reverting wholesale would re-introduce the silent-truncation bug, so instead narrow the rule: when voidresp raises an `ftplib.Error`, immediately follow up with an FTP `SIZE` query against the freshly-uploaded path. If the server-side size matches what Bambuddy just sent, the file is provably intact and Bambuddy proceeds with a warning (`FTP STOR returned error_temp for X but file is intact on the printer (N bytes match) — proceeding`). If the size doesn't match — or `SIZE` itself raises — the transfer was genuinely truncated (or the server is in too broken a state to be trusted) and the upload fails loudly with the error log path the previous round added (`server size=... expected=...`). Same logic is applied to both `upload_file()` and `upload_bytes()` so the legacy A1-compatibility manual-transfer path is covered identically. **Tests:** the two existing regressions from the previous round (`test_upload_426_data_stream_failure_returns_false`, `test_upload_bytes_426_data_stream_failure_returns_false`) are renamed and split: `test_upload_426_with_intact_file_proceeds` (SIZE matches → returns True, the reporter's case), `test_upload_426_with_truncated_file_returns_false` (SIZE smaller than expected → still fails, the original bug we don't want to regress), `test_upload_426_with_size_check_failing_returns_false` (SIZE itself raises → assume the worst), plus parallel coverage for `upload_bytes()`. The intact-file tests have to inject `SIZE` explicitly because the pyftpdlib mock only flushes the on-disk file after a clean voidresp — which doesn't happen when we monkeypatch voidresp to raise — and the docstring spells that out for future readers. 87 FTP unit tests green; ruff clean. The View-Timelapse-greyed-out behaviour the original #1417 report flagged stays untouched here — once the reporter confirms their upload reliability is back, that diagnosis continues on a healthy install. + +- **AMS: physically-empty slots now consistently report state=9 (#1322 follow-up, diagnosed by @RosdasHH)** — Reporter dug into the BambuStudio source and pointed out that our previous fix only caught the narrow `{"id": N}` bare-payload shape, which Bambu firmware only sends right after a printer restart. In steady-state operation — including the more common post-Reset-Slot path on P1S and the A1 Mini BMCU — firmware sends a populated payload with stale fields and signals emptiness via the `tray_exist_bits` bitmask instead. Bambuddy already parsed that bitmask at `bambu_mqtt.py:1758` (`slot_exists = (tray_exist_bits >> global_bit) & 1`) and used it to wipe stale `tray_type` / `tray_color` / `tag_uid` fields, but never promoted the slot's `state`. So downstream readers — the API serializer at `printers.py:457`, the `tray_state in {9, 10}` short-circuit in `inventory.py:1358`, the AMS card — all saw `state: null` and had to guess from absent payload fields. Reporter's API screenshot showed exactly that shape: `state: null, tray_color: null, remain: 0, ...`. Fix lifts a `tray["state"] = 9` assignment to the outer `if not slot_exists` branch (was nested inside the stale-data-clear branch), so the bitmask path now writes the canonical "no spool" state for every empty slot regardless of whether stale fields are present. Hard-typed as `int` 9, not string `"9"` — the downstream check at `inventory.py:1358` uses `tray_state == 9` (not `in {"9", 9}`), so a string would have silently missed and the reporter's deadlock would have come right back. The previous narrow heuristic in `printer_manager.py:797-798` (the `len(tray) == 1 and "id" in tray` shape detector) stays in place as belt-and-suspenders for the post-restart bare-payload edge that bypasses the AMS merge — costs nothing and protects against any MQTT path that doesn't flow through `_handle_ams_data`. The `state: null` surfacing on the API resolves automatically since `printers.py` reads `tray_data.get("state")` directly. **Tests:** two new in `test_bambu_mqtt.py::TestAMSDataHandling` — `test_tray_exist_bits_promotes_empty_slot_to_state_9` exercises the steady-state populated-payload path (slot occupied → bitmask flips bit 1 to 0 → state=9, type-asserted as int; loaded sibling slot keeps its state=11 unchanged); `test_tray_exist_bits_does_not_change_state_on_loaded_slots` pins the negative path (bitmask bit=1 with state=3 leaves state untouched — transitional firmware states like "unloading" don't get corrupted). The two `printer_manager.py` regression tests for the narrow heuristic (`test_bare_tray_emulates_state_9`, `test_populated_payload_with_empty_state_3_is_not_promoted`) stay green — that path is unchanged. 397 mqtt+printer-manager unit tests + 50 inventory/Spoolman slot-assignment integration tests = 447 affected tests green; ruff clean. **UI: visual distinction between physically empty and unconfigured slots (in the same drop).** With the data layer now consistent, the AMS slot card surfaces what Bambuddy actually knows about each empty slot, without overclaiming. New helper `getEmptySlotKind(tray)` in `PrintersPage.tsx` returns `"physical"` (state ∈ {9, 10} — firmware positively confirmed no spool), `"reset"` (any other empty state — could be a user-cleared assignment, mid-unload, or just a slot the firmware hasn't reported on yet), or `null` (loaded). The inline label below the slot circle reads `t('ams.slotEmpty')` ("Empty") uniformly for any empty slot (regular AMS, HT, external) so users get a consistent label everywhere — the previous version only said "Empty" for firmware-confirmed state=9 slots and fell back to an em-dash otherwise, which surfaced as "Empty" for regular AMS slots but "—" for HT AMS (skipped by the bitmask loop) and external trays (separate MQTT path entirely). The state distinction now lives only on the **border** and **hover card** where it doesn't surprise. `FilamentSlotCircle` gains an `emptyKind` prop that picks a quieter dashed border colour for unconfigured slots (`#3d3d3d` vs `#666`), so the visual hierarchy reads "loaded > unconfigured > physically empty" at a glance even though the inline text only differentiates physical from everything else. `EmptySlotHoverCard` gains a `kind` prop and switches the hover label between `ams.emptySlot` ("Empty slot") for physical and the new `ams.emptySlotReset` ("No filament assigned") for everything else — also rewritten from the original "Slot reset — no spool assigned" for the same overclaim reason. All three slot-render call sites in `PrintersPage` (regular AMS grid, HT AMS single-slot, external spool tray) now compute and pass the kind. i18n: 2 new keys (`slotEmpty`, `emptySlotReset`) translated across all 8 locales; parity at 4854 leaves. New test `#1322: empty slot kind is "physical" when state=9 and "reset" otherwise` in `PrintersPage.test.tsx` reuses the existing `phase13EmptySlotProps` mock to capture the `kind` prop across a 4-slot fixture (state=9 / state=3 / state=null / loaded) and asserts each variant flows through. 71 PrintersPage + FilamentHoverCard tests green; build clean. + +- **Stats page: Filament Used, By Time, and Success Rate now agree with Total Consumed and Total Prints (#1390 follow-up, reported by @IndividualGhost1905)** — After the archived-spool fix shipped the reporter confirmed it worked and gently flagged the round Bambuddy had explicitly postponed: Quick Stats `Filament Used` / `Filament Cost` didn't match `Total Consumed` on the Inventory page; Printer Stats `By Time` didn't match Quick Stats `Print Time`; the success-rate gauge percentage didn't relate to the `Total Prints` count shown right above it. Three independent root causes, fixed together. **(1) Filament Used vs Total Consumed.** `_compute_run_filament_grams` in `main.py` short-circuited to the slicer estimate for `status == "completed"` even when inventory had measured the actual AMS weight delta — the comment on the old test literally said "the print is done, so the full estimate is the right answer." That made Stats and Inventory two different sources of truth: Stats showed slicer-estimate grams, Inventory showed AMS-tracked grams, and the two numbers naturally diverged (slicer estimates are typically a few percent off real consumption). Fixed by reordering the helper so the tracked spool delta (sum of `usage_results[].weight_used` — same source that drives the per-spool `weight_used` counter behind Total Consumed) takes priority for *every* status. The slicer estimate stays as the fallback when no inventory was tracked for the print, and the partial-progress scale stays as the fallback for failed/cancelled/stopped with no tracker — so the existing #1378 partial-aware behaviour is preserved. The `_run_cost` block right next to it already used this priority order, so cost was always tracker-first; only `filament_used_grams` was inconsistent. New prints now record what was actually consumed, so Stats and Inventory show identical numbers. **(2) Printer Stats By Time vs Quick Stats Print Time.** `/archives/slim` only populated `actual_time_seconds` when `status == "completed"`. For failed/cancelled rows the field stayed null and the frontend (`StatsPage.tsx::PrinterStatsWidget`) fell back to `print_time_seconds` — the slicer's *estimated full-print duration*, which is the wrong number for a print that failed at 15% progress. Quick Stats `total_print_time_hours` already counted every event's elapsed `duration_seconds` regardless of status, so the two halves of the page disagreed by the (estimate − actual-elapsed) gap on every non-completed event. Dropped the `status == "completed"` gate in the slim row's `actual_time_seconds` computation; failed/cancelled events now report their measured elapsed time and the frontend's `actual || print_time` fallback chain only ever falls through to the slicer estimate for events with no measured duration at all. **(3) Success Rate %.** Formula was `successful / (successful + failed)`, denominator excluding `cancelled` / `stopped` / any other status. Combined with the visible "Total Prints: N" label right above the gauge, that produced confusing numbers: 4 successful, 0 failed, 48 cancelled showed 100% out of an apparent 52 prints. Switched to `successful / total_prints` — straightforward "what fraction of all attempts succeeded", matches the count the user reads from the widget header. The widget's `stats` prop already exposed `total_prints` so no type changes were needed. **(4) Records widget "Longest Print" — knock-on from (2).** Before (2), `actual_time_seconds` was null for non-completed rows so the Records widget's `findMax(a => a.actual_time_seconds)` implicitly only ranked successful prints. Once (2) populated the field for failed/cancelled events too, an aborted 25-hour print would have outranked a genuinely successful 18-hour print as "Longest Print" — a real semantic regression. Added a `status === 'completed'` gate on the longest getter only, restoring the pre-fix semantic. The other two records (Heaviest Print, Most Expensive) already included non-completed events via `filament_used_grams` and `cost` and intentionally stay as-is, since those values are populated by the partial-progress / tracker logic in `_compute_run_filament_grams` and were never gated on status. **Out of scope** — backfilling the 52 historical events on the reporter's database: `PrintLogEntry.filament_used_grams` is already baked in as the slicer estimate for older prints and we don't store per-event AMS deltas separately to backfill from. The reporter said upfront she'd "reset all statistics and start over" to track new prints cleanly, so this lands without a migration. Similarly the Failure Analysis 30-day default (a separate divergence the agent surfaced while mapping the page) wasn't part of the reporter's complaints and stays untouched. **Tests:** `test_run_filament_helper.py::test_completed_returns_estimate_even_when_tracked_differs` was renamed and inverted to `test_completed_prefers_tracked_over_estimate` — it now pins the new contract (completed + tracker → tracker value), guarding against a future "trust the estimate again" refactor. All 13 existing helper tests still green; the helper's contract changed in exactly one place and the rest (no-tracker fallback, partial-progress scaling, multi-slot summation) is unchanged. `test_archives_api.py::test_slim_actual_time_null_for_failed` was renamed to `test_slim_actual_time_for_failed_includes_elapsed` and inverted — same pattern, the old assertion is now the regression check. `StatsPage.test.tsx` gains two: `uses total_prints as denominator so cancelled/stopped events count (#1390)` (40 successful / 20 failed / 40 cancelled-or-stopped = 40%, matches Total Prints: 100, where the old formula would have shown 67%); and `Longest Print excludes failed prints (#1390)` pinning that an aborted 25-hour run can't outrank a successful 8-hour print as the Longest Print record — protects against a future refactor that removes the new status gate inside `findMax`. 33 StatsPage tests + 66 archive-API + run-filament tests green; frontend build clean. + +- **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 `#` 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 `#` inline with the brand/material/colour line; the existing `

` 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 `` 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 axes** — `await 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.py`** — `ReprintRequest.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 | None` — `True` 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_filament` — `name`, `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 `quantity` — `grep -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.py` — `test_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_entries` — `archive_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. + ## [0.2.4.1] - 2026-05-16 ### Added diff --git a/backend/app/api/routes/virtual_printers.py b/backend/app/api/routes/virtual_printers.py index 874bc0d24..ec69f9aa0 100644 --- a/backend/app/api/routes/virtual_printers.py +++ b/backend/app/api/routes/virtual_printers.py @@ -335,10 +335,17 @@ async def update_virtual_printer( if not vp: return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"}) + # Redact the access code before logging — model_dump otherwise includes + # the plaintext value at DEBUG, violating the project no-secrets-in-logs + # rule. Replace with a marker that still signals "the user changed it" + # vs "the user didn't touch this field". + _safe_body = body.model_dump(exclude_unset=True) + if "access_code" in _safe_body: + _safe_body["access_code"] = "***" logger.debug( "Update VP %d: body=%s, current state: mode=%s, enabled=%s, access_code_set=%s, bind_ip=%s, target=%s", vp_id, - body.model_dump(exclude_unset=True), + _safe_body, vp.mode, vp.enabled, bool(vp.access_code), @@ -492,10 +499,35 @@ async def delete_virtual_printer( # Stop instance if running await virtual_printer_manager.remove_instance(vp_id) + # Mark any PendingUpload rows that referenced this VP's upload_dir as + # discarded — without this the rows live on as phantom entries in + # /pending-uploads/ pointing at file paths that no longer exist, and + # the user only learns they're orphaned by trying to archive one and + # getting a flip-to-discarded on file-missing. + upload_prefix = str(virtual_printer_manager._base_dir / "uploads" / str(vp_id)) + try: + from backend.app.models.pending_upload import PendingUpload + + stale = await db.execute(select(PendingUpload).where(PendingUpload.file_path.startswith(upload_prefix))) + for pending in stale.scalars().all(): + pending.status = "discarded" + await db.flush() + except Exception as e: + logger.error("Failed to discard orphan PendingUpload rows for VP %d: %s", vp_id, e) + # Delete from DB await db.execute(sql_delete(VirtualPrinter).where(VirtualPrinter.id == vp_id)) await db.commit() + # Remove the on-disk upload directory after the DB commit succeeds, so + # a crash between commit and rmtree only leaves orphan files (vs orphan + # rows pointing at a now-missing tree). + upload_dir = virtual_printer_manager._base_dir / "uploads" / str(vp_id) + if upload_dir.exists(): + import shutil + + shutil.rmtree(upload_dir, ignore_errors=True) + logger.info("Deleted virtual printer: %s (id=%d)", vp_name, vp_id) # Resync remaining services diff --git a/backend/app/services/virtual_printer/bind_server.py b/backend/app/services/virtual_printer/bind_server.py index 9f9faaa08..94e1175cc 100644 --- a/backend/app/services/virtual_printer/bind_server.py +++ b/backend/app/services/virtual_printer/bind_server.py @@ -64,6 +64,11 @@ class BindServer: self._servers: list[asyncio.Server] = [] self._running = False + # Set after at least one bind port is listening — see ftp_server.py + # for rationale. Bind server is best-effort across BIND_PORTS, so + # "ready" means "at least one port bound", matching the existing + # serve_forever path. + self.ready = asyncio.Event() def _create_tls_context(self) -> ssl.SSLContext | None: """Create SSL context for the TLS bind port (3002).""" @@ -122,6 +127,7 @@ class BindServer: if not self._servers: logger.error("Bind server: could not bind to any port") return + self.ready.set() # Serve all successfully bound ports await asyncio.gather(*(s.serve_forever() for s in self._servers)) @@ -137,6 +143,7 @@ class BindServer: """Stop the bind server.""" logger.info("Stopping bind server") self._running = False + self.ready.clear() for server in self._servers: try: @@ -176,7 +183,14 @@ class BindServer: logger.warning("Bind server: unexpected command from %s: %s", client_id, request) return - # Build response + # Build response. `sequence_id` is an INTEGER counter chosen by + # the printer side (not an echo of the slicer's string seq_id). + # The protocol docstring at the top of this file documents the + # asymmetry: slicer sends `"20000"` (string), printer replies + # with an int. The hardcoded 3021 mirrors real-firmware-captured + # value; an earlier audit suggesting we echo the slicer's seq_id + # was wrong and would have broken slicers that validate the + # type (int vs string). response = { "login": { "bind": "free", diff --git a/backend/app/services/virtual_printer/certificate.py b/backend/app/services/virtual_printer/certificate.py index 620f52646..cc516d620 100644 --- a/backend/app/services/virtual_printer/certificate.py +++ b/backend/app/services/virtual_printer/certificate.py @@ -72,10 +72,59 @@ class CertificateService: Tuple of (cert_path, key_path) """ if self.cert_path.exists() and self.key_path.exists(): - logger.debug("Using existing virtual printer certificates") - return self.cert_path, self.key_path + if self._cert_matches_current_ca(): + logger.debug("Using existing virtual printer certificates") + return self.cert_path, self.key_path + logger.warning( + "Existing per-VP certificate's issuer doesn't match the current CA " + "(likely a CA rotation since the cert was signed). Regenerating " + "to keep the slicer's imported CA in sync with the served chain." + ) return self.generate_certificates() + def _cert_matches_current_ca(self) -> bool: + """Check whether the on-disk per-VP cert was signed by the current CA. + + Slicers that import the shared CA validate the per-VP cert against it. + If the CA has been rotated since the per-VP cert was signed, the chain + is broken even though both files exist on disk. ``ensure_certificates`` + uses this to decide whether to regenerate. + + Uses real signature verification — Bambuddy's auto-generated CAs all + share the same Subject DN ("Virtual Printer CA"), so a DN-only compare + would incorrectly return True even after rotation. + """ + try: + if not self.ca_cert_path.exists(): + # No CA yet — let generate_certificates create one and the + # matching per-VP chain. + return False + cert_pem = self.cert_path.read_bytes() + cert = x509.load_pem_x509_certificate(cert_pem) + ca_pem = self.ca_cert_path.read_bytes() + ca_cert = x509.load_pem_x509_certificate(ca_pem) + from cryptography.exceptions import InvalidSignature + from cryptography.hazmat.primitives.asymmetric import padding + + try: + ca_cert.public_key().verify( + cert.signature, + cert.tbs_certificate_bytes, + padding.PKCS1v15(), + cert.signature_hash_algorithm, + ) + return True + except InvalidSignature: + return False + except (OSError, ValueError) as e: + logger.debug("CA-match probe failed for %s: %s", self.cert_path, e) + return False + except Exception as e: + # Any unexpected exception during verification → treat as mismatch + # and regenerate. Safer than reusing a cert we can't validate. + logger.debug("CA-match verification failed for %s: %s", self.cert_path, e) + return False + def _load_existing_ca(self) -> tuple[rsa.RSAPrivateKey, x509.Certificate] | None: """Try to load existing CA certificate and key. @@ -123,8 +172,13 @@ class CertificateService: # Generate new CA ca_key, ca_cert = self._generate_ca_certificate() - # Save CA certificate and key - self.cert_dir.mkdir(parents=True, exist_ok=True) + # Save CA certificate and key. ``ca_key_path`` and ``ca_cert_path`` + # resolve under ``shared_ca_dir`` (which may differ from cert_dir), + # so the parent we need to mkdir is the CA file's parent — not + # cert_dir. Previously this created the per-VP subdirectory while + # the writes targeted the parent CA dir, which works only because + # the manager pre-creates both — the method itself was latent. + self.ca_key_path.parent.mkdir(parents=True, exist_ok=True) self.ca_key_path.write_bytes( ca_key.private_bytes( encoding=serialization.Encoding.PEM, diff --git a/backend/app/services/virtual_printer/diagnostic.py b/backend/app/services/virtual_printer/diagnostic.py index 144e3f64c..e1d6a9703 100644 --- a/backend/app/services/virtual_printer/diagnostic.py +++ b/backend/app/services/virtual_printer/diagnostic.py @@ -26,6 +26,7 @@ logger = logging.getLogger(__name__) PORT_FTPS = 990 # implicit FTPS — slicer file upload PORT_MQTT = 8883 # MQTT over TLS — control + status PORT_BIND = 3002 # bind/detect (TLS) — slicer discovery handshake +PORT_BIND_PLAIN = 3000 # bind/detect (plain) — legacy / some slicer models _PORT_PROBE_TIMEOUT = 2.0 @@ -134,14 +135,26 @@ async def run_vp_diagnostic(vp: VirtualPrinter, instance) -> VPDiagnosticResult: ) checks.append(DiagnosticCheck(id="port_bind", status="skip", params={"port": PORT_BIND})) else: - ftp_ok, mqtt_ok, bind_ok = await asyncio.gather( + # The non-proxy bind server listens on BOTH 3000 (plain) and 3002 + # (TLS) per bind_server.py BIND_PORTS — slicers pick either path. + # Probing only 3002 missed half-dead VPs where one listener failed + # to start and the other succeeded; report port_bind as pass only + # when both probes succeed. + ftp_ok, mqtt_ok, bind_tls_ok, bind_plain_ok = await asyncio.gather( _check_port(bind_ip, PORT_FTPS), _check_port(bind_ip, PORT_MQTT), _check_port(bind_ip, PORT_BIND), + _check_port(bind_ip, PORT_BIND_PLAIN), ) checks.append(DiagnosticCheck(id="port_ftps", status="pass" if ftp_ok else "fail", params={"port": PORT_FTPS})) checks.append(DiagnosticCheck(id="port_mqtt", status="pass" if mqtt_ok else "fail", params={"port": PORT_MQTT})) - checks.append(DiagnosticCheck(id="port_bind", status="pass" if bind_ok else "fail", params={"port": PORT_BIND})) + checks.append( + DiagnosticCheck( + id="port_bind", + status="pass" if (bind_tls_ok and bind_plain_ok) else "fail", + params={"port": PORT_BIND, "port_plain": PORT_BIND_PLAIN}, + ) + ) # --- TLS certificate --- # When running, the cert chain must exist on disk for the slicer's TLS diff --git a/backend/app/services/virtual_printer/ftp_server.py b/backend/app/services/virtual_printer/ftp_server.py index 55022cdcb..bc6e468ec 100644 --- a/backend/app/services/virtual_printer/ftp_server.py +++ b/backend/app/services/virtual_printer/ftp_server.py @@ -8,6 +8,7 @@ immediately upon connection, before any FTP commands are exchanged. """ import asyncio +import hmac import logging import os import random @@ -24,6 +25,14 @@ logger = logging.getLogger(__name__) # Requires CAP_NET_BIND_SERVICE or root. FTP_PORT = 990 +# Hard cap on a single upload. 4 GiB covers the largest realistic +# multi-plate .gcode.3mf and rejects runaway / malicious clients before +# they can exhaust the disk or OOM the host. STOR still buffers the +# whole file in memory before write_bytes — peak RSS ~2x file size during +# the b''.join — so the cap also caps that peak. If real users hit it +# with a legitimate file, raise here. +MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024 # 4 GiB + class FTPSession: """Handles a single FTP client session.""" @@ -162,7 +171,10 @@ class FTPSession: async def cmd_PASS(self, arg: str) -> None: """Handle PASS command.""" if self.username and self.username.lower() == "bblp": - if arg == self.access_code: + # ``hmac.compare_digest`` is constant-time — keeps the auth check + # from leaking the access code via response timing under network + # jitter. LAN-only threat is marginal; this is the standard fix. + if hmac.compare_digest(arg, self.access_code): self.authenticated = True await self.send(230, "Login successful") logger.info("%sFTP login from %s", self._log_prefix, self.remote_ip) @@ -380,7 +392,19 @@ class FTPSession: await asyncio.sleep(0.1) async def cmd_STOR(self, arg: str) -> None: - """Handle STOR command - receive file upload.""" + """Handle STOR command - receive file upload. + + Streams each chunk directly to disk inside the receive loop instead + of buffering the whole file in a ``list[bytes]`` and joining at the + end. Wire protocol unchanged — same 150/226/426 sequence, same + single-write target path (no ``.part`` or atomic rename), no new + verbs, no concurrency guard. The visible behaviour difference is + that the destination file grows progressively during upload rather + than appearing all-at-once on completion; slicers don't LIST during + STOR, so this isn't observable. Peak RSS for a multi-GB upload + drops from ~2× file size to one chunk (64 KiB). + ``MAX_UPLOAD_BYTES`` cap kept — purely server-internal DoS guard. + """ if not self.authenticated: await self.send(530, "Not logged in") return @@ -410,22 +434,23 @@ class FTPSession: await self._close_data_connection() return - # Receive data - data_content: list[bytes] = [] + # Receive + stream to disk total_received = 0 + write_failed: Exception | None = None try: - while True: - chunk = await asyncio.wait_for(self._data_reader.read(65536), timeout=60) - if not chunk: - break - data_content.append(chunk) - total_received += len(chunk) - logger.debug("FTP received chunk: %s bytes (total: %s)", len(chunk), total_received) + with file_path.open("wb") as f: + while True: + chunk = await asyncio.wait_for(self._data_reader.read(65536), timeout=60) + if not chunk: + break + total_received += len(chunk) + if total_received > MAX_UPLOAD_BYTES: + raise OSError(f"upload exceeded size cap ({total_received} > {MAX_UPLOAD_BYTES} bytes)") + f.write(chunk) + logger.debug("FTP received chunk: %s bytes (total: %s)", len(chunk), total_received) except TimeoutError: logger.error("FTP data transfer timeout after %s bytes for %s", total_received, filename) - await self.send(426, "Transfer timeout") - await self._close_data_connection() - return + write_failed = TimeoutError("Transfer timeout") except Exception as e: logger.error( "FTP data transfer error after %s bytes for %s: %s(%s)", @@ -434,32 +459,32 @@ class FTPSession: type(e).__name__, e, ) - await self.send(426, f"Transfer failed: {e}") - await self._close_data_connection() - return + write_failed = e # Close data connection await self._close_data_connection() - # Write file - try: - total_size = sum(len(c) for c in data_content) - file_path.write_bytes(b"".join(data_content)) - logger.info("FTP saved file: %s (%s bytes)", file_path, total_size) - await self.send(226, "Transfer complete") + if write_failed is not None: + # Drop the partial file so it doesn't masquerade as a complete + # upload — buffer-then-write never had a partial-file footprint. + try: + file_path.unlink(missing_ok=True) + except OSError: + pass + await self.send(426, f"Transfer failed: {write_failed}") + return - # Notify callback - if self.on_file_received: - try: - result = self.on_file_received(file_path, self.remote_ip) - if asyncio.iscoroutine(result): - await result - except Exception as e: - logger.error("File received callback error: %s", e) + # Confirm + notify + logger.info("FTP saved file: %s (%s bytes)", file_path, total_received) + await self.send(226, "Transfer complete") - except Exception as e: - logger.error("Failed to save file %s: %s", file_path, e) - await self.send(550, "Failed to save file") + if self.on_file_received: + try: + result = self.on_file_received(file_path, self.remote_ip) + if asyncio.iscoroutine(result): + await result + except Exception as e: + logger.error("File received callback error: %s", e) async def cmd_SIZE(self, arg: str) -> None: """Handle SIZE command.""" @@ -514,7 +539,18 @@ class FTPSession: await self.send(257, f'"{arg}" directory created') async def cmd_LIST(self, arg: str) -> None: - """Handle LIST command - list directory contents.""" + """Handle LIST command - list directory contents. + + Intentionally answers 150 + 226 without opening the passive data + channel. Bambuddy is an upload-only VP — no slicer in capture logs + actually issues LIST during the project_file flow, so the + no-data-conn ack is what every observed slicer accepts. A previous + audit recommended opening + closing the data conn for protocol + purity; reverted because (a) the bug was theoretical, (b) slicer + compatibility matters more than RFC purity here, and (c) adding + NLST/MLSD alongside changes the "supported verbs" surface in a way + we cannot regression-test without every supported slicer build. + """ if not self.authenticated: await self.send(530, "Not logged in") return @@ -526,8 +562,14 @@ class FTPSession: class VirtualPrinterFTPServer: """Implicit FTPS server that accepts uploads from slicers.""" + # Passive-mode data port range. Widened from 50000-50100 (101 ports) to + # 50000-51000 (1001 ports) so concurrent transfers across multiple VPs + # — particularly when a VP falls back to bind 0.0.0.0 (manager.py picks + # this when bind_ip is unset) — don't collide. With 101 ports and 10 + # random pick attempts per session, birthday-style collisions hit + # under load; 1001 ports gives multi-VP setups headroom. PASSIVE_PORT_MIN = 50000 - PASSIVE_PORT_MAX = 50100 + PASSIVE_PORT_MAX = 51000 def __init__( self, @@ -562,6 +604,12 @@ class VirtualPrinterFTPServer: self.vp_name = vp_name self._server: asyncio.Server | None = None self._running = False + # Set after the socket is bound and the server is accepting connections, + # so VirtualPrinterInstance.start_server can wait for readiness before + # reporting is_running=True. Without this, a caller racing the start + # could probe the port and see "connection refused" while is_running + # already says yes. + self.ready = asyncio.Event() self._ssl_context: ssl.SSLContext | None = None self._active_sessions: list[asyncio.Task] = [] # Override PASV response IP for Docker bridge mode / NAT environments @@ -579,7 +627,14 @@ class VirtualPrinterFTPServer: cache_dir = self.upload_dir / "cache" cache_dir.mkdir(exist_ok=True) - # Create SSL context for implicit FTPS (TLS from byte 0) + # Create SSL context for implicit FTPS (TLS from byte 0). + # Pinned to TLS 1.2 only. Allowing 1.3 broke BambuStudio mid-upload + # in the field (session_reused=True on data channel via PSK + libcurl + # CURLE_PARTIAL_FILE / RST after ~80 KiB; "server did not report OK, + # got 426"). Real Bambu printers also serve their FTPS at 1.2 only, + # and the slicer expects to match that. A future slicer drop of 1.2 + # is a problem to solve when it actually happens; until then 1.2 is + # mandatory for compat. self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) self._ssl_context.load_cert_chain(str(self.cert_path), str(self.key_path)) self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2 @@ -599,6 +654,7 @@ class VirtualPrinterFTPServer: ssl=self._ssl_context, # This makes it implicit FTPS! ) self._running = True + self.ready.set() logger.info("Implicit FTPS server started on port %s", self.port) logger.info( @@ -657,14 +713,17 @@ class VirtualPrinterFTPServer: """Stop the FTPS server.""" logger.info("Stopping FTP server") self._running = False + self.ready.clear() - # Cancel all active sessions first - for task in self._active_sessions[:]: # Copy list to avoid modification during iteration + # Cancel all active sessions and AWAIT cancellation. Previously + # this slept 0.1 s and called it good — a session mid-write, + # mid-TLS handshake, or holding a 60 s data-read could easily + # outlive that and then ``_server.close()`` would run while the + # underlying sockets were still in use. + for task in self._active_sessions[:]: task.cancel() - - # Wait briefly for sessions to clean up if self._active_sessions: - await asyncio.sleep(0.1) + await asyncio.gather(*self._active_sessions, return_exceptions=True) self._active_sessions.clear() diff --git a/backend/app/services/virtual_printer/manager.py b/backend/app/services/virtual_printer/manager.py index 14f7c6124..192e11659 100644 --- a/backend/app/services/virtual_printer/manager.py +++ b/backend/app/services/virtual_printer/manager.py @@ -47,6 +47,8 @@ VIRTUAL_PRINTER_MODELS = { "N1": "A1 Mini", # A1 Mini # H2 Series "O1D": "H2D", # H2D + "O1E": "H2D Pro", # H2D Pro + "O2D": "H2D Pro", # H2D Pro "O1C": "H2C", # H2C "O1C2": "H2C", # H2C (dual nozzle variant) "O1S": "H2S", # H2S @@ -77,6 +79,8 @@ MODEL_SERIAL_PREFIXES = { "N1": "03000A", # A1 Mini # H2 Series "O1D": "09400A", # H2D + "O1E": "09400A", # H2D Pro (same prefix family as H2D) + "O2D": "09400A", # H2D Pro "O1C": "09400A", # H2C "O1C2": "09400A", # H2C (dual nozzle variant) "O1S": "09400A", # H2S @@ -88,6 +92,14 @@ DISPLAY_NAME_TO_MODEL_CODE = {v: k for k, v in VIRTUAL_PRINTER_MODELS.items()} # Default model DEFAULT_VIRTUAL_PRINTER_MODEL = "BL-P001" # X1C +# Bound on per-instance ``_slicer_print_options`` cache size. The slicer's +# project_file MQTT command stashes one dict per filename; the +# corresponding ``_add_to_print_queue`` pop only fires when the file +# upload completes. Failed / cancelled / non-3MF uploads orphan their +# stash. The bound triggers FIFO eviction in ``on_print_command`` once +# the dict fills, so a long-running VP can't leak unbounded state. +_SLICER_OPTIONS_CACHE_LIMIT = 128 + def _get_serial_for_model(model: str, serial_suffix: str) -> str: """Get serial number for the given model and suffix.""" @@ -241,6 +253,12 @@ class VirtualPrinterInstance: # against the same field during the upload window. if self._mqtt and file_path.suffix.lower() == ".3mf": self._mqtt.set_gcode_state("FINISH", filename=file_path.name, prepare_percent="100") + # FINISH is the terminal state for the upload cycle per #1280 + # (commit 0d6171dc). The Print-flow slicer's in-flight-job lock + # releases on FINISH; resetting to IDLE 2 s later would re-confuse + # the slicer that just unwedged. Earlier audit suggesting the + # IDLE reset was wrong — staying at FINISH is the designed + # behaviour. The next upload's PREPARE→FINISH cycle starts fresh. async def on_print_command(self, filename: str, data: dict) -> None: """Handle print command from MQTT. @@ -257,6 +275,19 @@ class VirtualPrinterInstance: logger.info("[VP %s] Print command for: %s", self.name, filename) if self.mode != "print_queue": return + # Drop the oldest stash if the cache is growing — happens when the + # slicer sends project_file for a filename whose FTP upload was + # rejected / cancelled / non-3MF, so _add_to_print_queue's pop + # never fires. With no bound, a long-running VP accumulates one + # dict per such mismatch. + if len(self._slicer_print_options) >= _SLICER_OPTIONS_CACHE_LIMIT: + try: + stale_key = next(iter(self._slicer_print_options)) + self._slicer_print_options.pop(stale_key, None) + self._slicer_print_options_events.pop(stale_key, None) + logger.debug("[VP %s] Evicted stale slicer options for %s", self.name, stale_key) + except StopIteration: + pass self._slicer_print_options[filename] = dict(data) event = self._slicer_print_options_events.get(filename) if event: @@ -277,6 +308,7 @@ class VirtualPrinterInstance: pass return + archived = False try: from backend.app.api.routes.settings import get_setting from backend.app.services.archive import ArchiveService @@ -298,15 +330,29 @@ class VirtualPrinterInstance: if archive: logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name) await self._broadcast_archive_created(archive) - try: - file_path.unlink() - except OSError: - pass - self._pending_files.pop(file_path.name, None) + archived = True else: logger.error("Failed to archive file: %s", file_path.name) except Exception as e: logger.error("Error archiving file: %s", e) + finally: + # Always release the in-flight marker and delete the temp file — + # previously the failure paths only logged and the next upload of + # the same name was silently rejected with "already uploading", + # the upload_dir filled up indefinitely, and the slicer received + # a clean 226 even though no archive existed (#audit-R2-1). + self._pending_files.pop(file_path.name, None) + if archived: + try: + file_path.unlink() + except OSError: + pass + else: + # Drop the failed temp file so it doesn't accumulate. + try: + file_path.unlink(missing_ok=True) + except OSError: + pass async def _queue_file(self, file_path: Path, source_ip: str) -> None: """Queue file for user review.""" @@ -354,9 +400,19 @@ class VirtualPrinterInstance: db.add(pending) await db.commit() logger.info("[VP %s] Queued: %s - %s", self.name, pending.id, file_path.name) - self._pending_files.pop(file_path.name, None) except Exception as e: logger.error("Error queueing file: %s", e) + # Queue insert failed — drop the temp file so it doesn't + # accumulate. The file is unreachable without the DB row. + try: + file_path.unlink(missing_ok=True) + except OSError: + pass + finally: + # Always release the in-flight marker so concurrent uploads + # with the same filename aren't spuriously rejected after + # a queue failure. + self._pending_files.pop(file_path.name, None) async def _add_to_print_queue(self, file_path: Path, source_ip: str) -> None: """Archive file and add to print queue, assigned to target printer or model.""" @@ -492,12 +548,33 @@ class VirtualPrinterInstance: if overrides: filament_overrides_json = json.dumps(overrides) + # Pick the next free position the same way the manual + # /print-queue/ POST does — previously hardcoded to 1, + # which created duplicate position=1 rows on every + # VP upload and made queue execution order + # non-deterministic for any non-empty queue. + from sqlalchemy import func, select as _sql_select + + queue_scope = _sql_select(func.max(PrintQueueItem.position)).where( + PrintQueueItem.status == "pending" + ) + if self.target_printer_id is not None: + queue_scope = queue_scope.where(PrintQueueItem.printer_id == self.target_printer_id) + else: + queue_scope = queue_scope.where(PrintQueueItem.printer_id.is_(None)) + try: + max_pos_raw = (await db.execute(queue_scope)).scalar() + max_pos = int(max_pos_raw) if max_pos_raw is not None else 0 + except (TypeError, ValueError): + max_pos = 0 + next_position = max_pos + 1 + queue_item = PrintQueueItem( printer_id=self.target_printer_id, target_model=target_model, archive_id=archive.id, plate_id=plate_id, - position=1, + position=next_position, status="pending", manual_start=not self.auto_dispatch, required_filament_types=required_filament_types_json, @@ -512,15 +589,20 @@ class VirtualPrinterInstance: await db.commit() logger.info("[VP %s] Added to queue: %s", self.name, queue_item.id) await self._broadcast_archive_created(archive) - try: - file_path.unlink() - except OSError: - pass - self._pending_files.pop(file_path.name, None) else: logger.error("Failed to archive file: %s", file_path.name) except Exception as e: logger.error("Error adding to print queue: %s", e) + finally: + # Always release the marker and clean the temp file. Without this + # the same-name STOR guard would block the next upload and the + # upload_dir would accumulate failed temp files forever + # (#audit-R2-1). + self._pending_files.pop(file_path.name, None) + try: + file_path.unlink(missing_ok=True) + except OSError: + pass async def _broadcast_archive_created(self, archive) -> None: """Notify connected clients that a new archive exists. @@ -560,7 +642,12 @@ class VirtualPrinterInstance: for meta in plate.findall("metadata"): if meta.get("key") == "index" and meta.get("value"): return int(meta.get("value")) - except Exception: + except Exception as e: + # Malformed / missing slice_info.config — fall through to None. + # Logged at debug so a non-3MF or unconventional 3MF doesn't + # spam production logs; a debug trail exists for support + # bundles when wrong-plate dispatches are reported. + logger.debug("[VP] _extract_plate_id failed for %s: %s", file_path.name, e) return None return None @@ -682,8 +769,11 @@ class VirtualPrinterInstance: ) ) - # SSDP server — advertise_addr is the Tailscale FQDN when available, - # otherwise the bind/remote IP (existing behaviour) + # SSDP server — advertise_addr is the remote_interface_ip (Tailscale + # IP, when chosen from the bind_ip dropdown) or the bind_ip. SSDP + # Location accepts IPs only; FQDNs go in through bind_ip selection + # at the printer-IP level and resolve before reaching the SSDP + # advertisement. self._ssdp = VirtualPrinterSSDPServer( name=self.name, serial=self.serial, @@ -698,6 +788,32 @@ class VirtualPrinterInstance: ) ) + # Wait briefly for every child service to actually finish binding its + # socket so ``is_running`` doesn't lie. Without this barrier a caller + # racing the start (e.g. the diagnostic route) would see is_running=True + # while ports were still in the gap between task creation and the + # ``asyncio.start_server`` returning. Bounded timeout — if a child + # hangs we log it and move on; the existing task tracking still + # catches the failure on the next iteration. + ready_targets = [ + ("FTP", self._ftp.ready), + ("MQTT", self._mqtt.ready), + ("Bind", self._bind.ready), + ("SSDP", self._ssdp.ready), + ] + try: + await asyncio.wait_for( + asyncio.gather(*(e.wait() for _, e in ready_targets)), + timeout=5.0, + ) + except TimeoutError: + not_ready = [name for name, e in ready_targets if not e.is_set()] + logger.warning( + "[VP %s] Sub-service(s) didn't bind within 5s: %s — continuing anyway", + self.name, + ", ".join(not_ready) or "(none)", + ) + logger.info("[VP %s] Server-mode services started on %s", self.name, bind_addr) async def stop_server(self) -> None: @@ -850,6 +966,13 @@ class VirtualPrinterManager: self._session_factory: Callable | None = None self._printer_manager: PrinterManager | None = None self._instances: dict[int, VirtualPrinterInstance] = {} + # Serialize sync_from_db so concurrent PUT /vp/{id} calls can't + # race the start/stop sequence and leave duplicate sub-services + # bound to the same port. The lock is fine-grained enough that + # a single VP update completes in well under a second; if the + # user holds the lock with a long-running start they intended + # to anyway. + self._sync_lock = asyncio.Lock() # Directories self._base_dir = app_settings.base_dir / "virtual_printer" @@ -895,11 +1018,22 @@ class VirtualPrinterManager: return len(self._instances) > 0 async def sync_from_db(self) -> None: - """Load all VPs from DB, reconcile running state.""" + """Load all VPs from DB, reconcile running state. + + Serialised by ``self._sync_lock`` — concurrent PUT /vp/{id} routes + all call into this method; without the lock the start / stop + sequence races and can leave duplicate sub-services bound to the + same port or orphan still-running tasks. + """ if not self._session_factory: logger.warning("Cannot sync virtual printers: no session factory") return + async with self._sync_lock: + await self._sync_from_db_locked() + + async def _sync_from_db_locked(self) -> None: + """Inner sync body — caller holds ``self._sync_lock``.""" from sqlalchemy import select from backend.app.models.printer import Printer @@ -935,6 +1069,22 @@ class VirtualPrinterManager: if not instance: continue + # Proxy mode: detect target printer IP / serial changes from the + # DB lookup above. Without this branch a DHCP renewal that gives + # the target printer a new IP would leave the running proxy + # forwarding to the stale IP until the user manually toggles the + # VP. The same shape covers a target-side serial change. + proxy_target_changed = False + if vp.mode == "proxy": + fresh = proxy_ips.get(vp.id) + if fresh is not None: + fresh_ip, fresh_serial = fresh + if ( + getattr(instance, "target_printer_ip", None) != fresh_ip + or getattr(instance, "target_printer_serial", None) != fresh_serial + ): + proxy_target_changed = True + changed = ( instance.mode != vp.mode or instance.model != (vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL) @@ -943,6 +1093,11 @@ class VirtualPrinterManager: or instance.remote_interface_ip != (vp.remote_interface_ip or "") or instance.target_printer_id != vp.target_printer_id or instance.auto_dispatch != vp.auto_dispatch + # Queue-mode behaviour toggle — without it the running + # instance silently keeps the old value until process + # restart (#1552 follow-up family). + or instance.queue_force_color_match != vp.queue_force_color_match + or proxy_target_changed ) if changed: diff --git a/backend/app/services/virtual_printer/mqtt_bridge.py b/backend/app/services/virtual_printer/mqtt_bridge.py index 597b09a97..5a589110a 100644 --- a/backend/app/services/virtual_printer/mqtt_bridge.py +++ b/backend/app/services/virtual_printer/mqtt_bridge.py @@ -65,6 +65,20 @@ _SLICER_VISIBLE_STICKY_KEYS: tuple[str, ...] = ( "net", "ipcam", "lights_report", + # Pre-flight / Prepare-tab fields that BambuStudio reads off cached + # push_status. Bambu firmware emits them in full pushall but typically + # OMITS them from 1 Hz incremental updates, so without sticky-preservation + # the cache drops them after the very next tick and the slicer's + # "block Send while busy / unknown firmware" branch kicks in. Same shape + # as #1228 (storage indicators) and #1558 (live-progress fields) — + # cached-branch field-shape parity, not a new mechanism. + "upgrade_state", # Send pre-flight reads dis_state / force_upgrade + "xcam", # Prepare-tab reads spaghetti / first-layer / halt sensitivity + "hw_switch_state", # Hardware switch state (Prepare tab) + "nozzle_diameter", + "nozzle_type", + "online", # Module online map (ahb / rfid / version) + "ams_status", # AMS overall status; can be ams_status-only incremental ) @@ -241,6 +255,11 @@ class MQTTBridge: BambuMQTTClient is destroyed and recreated on PrinterManager.connect_printer (e.g. printer config update). Without periodic refresh the bridge would lose fan-out after such a churn until the VP itself restarts. + + On crash exit, the handler must be unbound — otherwise the registered + ``_on_printer_raw`` keeps firing on every real-printer message even + though the bridge is functionally dead (memory leak + behaviour leak + across VP restart). """ try: while not self._stopping: @@ -250,6 +269,9 @@ class MQTTBridge: raise except Exception: logger.exception("[%s] MQTT bridge refresh loop crashed", self.vp_name) + # Crash exit — unbind so the orphaned handler stops firing. + # ``stop()`` won't be invoked because the task completes done-not-cancelled. + self._unbind_client() def _resolve_client(self) -> None: """Look up the current client for target_printer_id and rebind if it changed.""" @@ -402,7 +424,11 @@ class MQTTBridge: for sticky_key in _SLICER_VISIBLE_STICKY_KEYS: if sticky_key not in new_state: if sticky_key in prev: - new_state[sticky_key] = prev[sticky_key] + # Defensive deep copy — without this the carried-over + # nested dicts/lists are shared between new_state and + # the previous cache, so any in-place mutation later + # (current or future code paths) would corrupt both. + new_state[sticky_key] = copy.deepcopy(prev[sticky_key]) continue # Key IS in new_state — but firmware sends partial blobs # (status-only / tray-targeted) under the same key on diff --git a/backend/app/services/virtual_printer/mqtt_server.py b/backend/app/services/virtual_printer/mqtt_server.py index 297672019..3e65fd341 100644 --- a/backend/app/services/virtual_printer/mqtt_server.py +++ b/backend/app/services/virtual_printer/mqtt_server.py @@ -5,6 +5,8 @@ authenticates with the configured access code, and logs print commands. """ import asyncio +import copy +import hmac import json import logging import ssl @@ -20,6 +22,22 @@ logger = logging.getLogger(__name__) # Default MQTT port for Bambu printers (MQTT over TLS) MQTT_PORT = 8883 +# Per-IP MQTT auth rate-limit. 5 failures within 60 s blocks further attempts +# for the remainder of the window. Bambu printers themselves don't rate-limit, +# but they're not exposed past the LAN edge; Bambuddy's VPs sometimes are +# (Tailscale, port-forwarded), so an 8-char access code without any +# brute-force friction is too weak. The window auto-recovers — no manual +# unblock — so a legitimate user who fat-fingered their access code 5 times +# only waits up to 60 s. +_AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5 +_AUTH_RATE_LIMIT_WINDOW_SECONDS = 60.0 + +# Pending-request map bound. Each entry maps a slicer command's +# sequence_id to its originating client_id so the bridge response can be +# routed back to just that client. Bounded so a slicer that issues +# commands without ever consuming responses can't leak memory. +_PENDING_REQUEST_MAX_ENTRIES = 256 + # Model code → product_name for version response (must match what slicer expects) MODEL_PRODUCT_NAMES = { "BL-P001": "X1 Carbon", @@ -204,6 +222,8 @@ class SimpleMQTTServer: self.vp_name = vp_name self._log_prefix = f"[{vp_name}] " if vp_name else "" self._running = False + # Set after the socket is bound — see ftp_server.py for rationale. + self.ready = asyncio.Event() self._server = None self._clients: dict[str, asyncio.StreamWriter] = {} # Per-client "effective serial" — the serial the slicer actually uses in @@ -228,6 +248,20 @@ class SimpleMQTTServer: # synthetic fallback resumes automatically. self._bridge: MQTTBridge | None = None + # Per-source-IP failed-auth tracker for rate-limiting / lockout. + # Maps IP → list[monotonic timestamp] of recent failures within the + # window. Pruned on every check so it doesn't grow unbounded. + self._auth_failures: dict[str, list[float]] = {} + + # Maps sequence_id → originating client_id for slicer-initiated + # commands forwarded to the real printer. Used in + # ``push_raw_to_clients`` to route the printer's response only + # back to the requesting slicer instead of fanning out to all + # connected clients (which leaks slicer A's responses to slicer + # B in multi-slicer setups). FIFO-bounded; if a response never + # arrives the entry ages out instead of leaking. + self._pending_requests: dict[str, str] = {} + async def start(self) -> None: """Start the MQTT server.""" if self._running: @@ -287,6 +321,7 @@ class SimpleMQTTServer: self.port, ssl=ssl_context, ) + self.ready.set() logger.info("Simple MQTT server listening on port %s", self.port) @@ -312,6 +347,7 @@ class SimpleMQTTServer: """Stop the MQTT server.""" logger.info("Stopping simple MQTT server") self._running = False + self.ready.clear() # Stop periodic status push if self._status_push_task: @@ -392,10 +428,17 @@ class SimpleMQTTServer: logger.info("Periodic status push task stopped") async def push_raw_to_clients(self, topic: str, payload: bytes) -> None: - """Publish a pre-serialized MQTT payload on `topic` to every connected slicer. + """Publish a pre-serialized MQTT payload on `topic` to connected slicers. Called by MQTTBridge from the asyncio loop (scheduled via run_coroutine_threadsafe from paho's network thread). + + Routes the response only back to the originating slicer if the + payload's sequence_id was previously recorded via + ``_record_pending_request``. Falls back to fan-out for + printer-initiated pushes (push_status etc.) and for sequence_ids + we never saw (covers a slicer that subscribes mid-flight to a + topic for which an earlier request is still in flight). """ topic_bytes = topic.encode("utf-8") # MQTT remaining-length: 2-byte topic length prefix + topic + message body. @@ -414,8 +457,12 @@ class SimpleMQTTServer: packet.extend(payload) frame = bytes(packet) + target_client_id = self._lookup_pending_request_client(payload) + disconnected = [] for client_id, writer in list(self._clients.items()): + if target_client_id is not None and client_id != target_client_id: + continue try: if writer.is_closing(): disconnected.append(client_id) @@ -470,9 +517,23 @@ class SimpleMQTTServer: # Handle packet types if packet_type == 1: # CONNECT + source_ip = addr[0] if addr else "unknown" + if self._is_auth_rate_limited(source_ip): + logger.warning( + "%sMQTT auth rate-limited from %s (>=%d failures in %ds)", + self._log_prefix, + source_ip, + _AUTH_RATE_LIMIT_MAX_ATTEMPTS, + int(_AUTH_RATE_LIMIT_WINDOW_SECONDS), + ) + writer.write(bytes([0x20, 0x02, 0x00, 0x05])) # Not authorized + await writer.drain() + break authenticated, keep_alive = await self._handle_connect(payload, writer) if not authenticated: + self._record_auth_failure(source_ip) break + self._clear_auth_failures(source_ip) # Honour the client's negotiated keepalive (#1548). Before # this fix, the hardcoded 60 s above would close # OrcaSlicer's idle connection at the keepalive boundary @@ -501,7 +562,11 @@ class SimpleMQTTServer: except asyncio.CancelledError: pass # Expected when server is shutting down and cancels client tasks except Exception as e: - logger.debug("MQTT client error: %s", e) + # Outer handler — inner handlers already absorb expected parser + # / IO failures at debug. Anything reaching here is unexpected + # and would otherwise silently drop the slicer connection with + # no actionable signal in production logs (defaults are INFO+). + logger.warning("%sMQTT client session error from %s: %s", self._log_prefix, client_id, e) finally: logger.debug("MQTT client disconnected: %s", client_id) self._clients.pop(client_id, None) @@ -532,6 +597,79 @@ class SimpleMQTTServer: return None + def _record_pending_request(self, data: dict, client_id: str) -> None: + """Stash sequence_id → client_id for any nested block with a sequence_id. + + Slicer commands typically wrap their seq id in ``{"print": {...}}`` or + ``{"info": {...}}`` / ``{"system": {...}}`` etc. Walks top-level dict + values once to find the seq id; if absent (some commands omit it) we + skip — the response will fall through to broadcast which is fine for + unsolicited pushes. + """ + for block in data.values(): + if isinstance(block, dict): + seq = block.get("sequence_id") + if seq is not None: + key = str(seq) + # Evict oldest entry when over the cap. Python dicts + # preserve insertion order so iter(self._pending_requests) + # yields the oldest key first. + while len(self._pending_requests) >= _PENDING_REQUEST_MAX_ENTRIES: + oldest = next(iter(self._pending_requests)) + self._pending_requests.pop(oldest, None) + self._pending_requests[key] = client_id + return + + def _lookup_pending_request_client(self, payload: bytes) -> str | None: + """Parse a bridge-forwarded MQTT payload and return the originating + client_id if its sequence_id was recorded. + + Returns ``None`` for printer-initiated pushes (no recorded seq id) so + push_raw_to_clients falls back to broadcast — required for push_status + and the other unsolicited pushes that every connected slicer expects. + """ + try: + parsed = json.loads(payload) + except (ValueError, TypeError): + return None + if not isinstance(parsed, dict): + return None + for block in parsed.values(): + if isinstance(block, dict): + seq = block.get("sequence_id") + if seq is not None: + return self._pending_requests.pop(str(seq), None) + return None + + def _is_auth_rate_limited(self, source_ip: str) -> bool: + """Return True if ``source_ip`` has hit the per-IP failure cap. + + Prunes timestamps older than the window so the dict doesn't grow + unbounded. Uses ``time.monotonic()`` for a wall-clock-jump-immune + clock that's safe to call from any context (sync or async). + """ + import time as _time + + now = _time.monotonic() + window_start = now - _AUTH_RATE_LIMIT_WINDOW_SECONDS + recent = [t for t in self._auth_failures.get(source_ip, []) if t >= window_start] + if recent: + self._auth_failures[source_ip] = recent + else: + self._auth_failures.pop(source_ip, None) + return len(recent) >= _AUTH_RATE_LIMIT_MAX_ATTEMPTS + + def _record_auth_failure(self, source_ip: str) -> None: + """Append a timestamp for ``source_ip``'s failed auth attempt.""" + import time as _time + + now = _time.monotonic() + self._auth_failures.setdefault(source_ip, []).append(now) + + def _clear_auth_failures(self, source_ip: str) -> None: + """Reset ``source_ip``'s failure history after a successful auth.""" + self._auth_failures.pop(source_ip, None) + async def _handle_connect(self, payload: bytes, writer: asyncio.StreamWriter) -> tuple[bool, int]: """Handle MQTT CONNECT packet. @@ -576,8 +714,11 @@ class SimpleMQTTServer: idx += 2 password = payload[idx : idx + password_len].decode("utf-8") - # Authenticate - if username == "bblp" and password == self.access_code: + # Authenticate. ``hmac.compare_digest`` is constant-time to keep + # the auth check from leaking the access code via response timing + # under network jitter — LAN-only threat is marginal, but it's + # the standard fix and costs nothing. + if username == "bblp" and hmac.compare_digest(password, self.access_code): # Send CONNACK with success writer.write(bytes([0x20, 0x02, 0x00, 0x00])) await writer.drain() @@ -668,7 +809,13 @@ class SimpleMQTTServer: if isinstance(cached, dict): # Real-printer-shaped response. Copy the cache, then replace the # protocol / upload-state fields with values under our control. - print_block = dict(cached) + # Deep copy — current mutations are top-level only, but a future + # override that writes into a nested dict (e.g. ``online``, + # ``upgrade_state``, ``ipcam``) would otherwise corrupt the + # bridge cache and be read by every subsequent subscriber until + # the next real-printer push lands. Cost is one allocation per + # status report; the cached dict is already short-lived. + print_block = copy.deepcopy(cached) print_block["sequence_id"] = str(self._sequence_id) print_block["command"] = "push_status" print_block["msg"] = 0 @@ -694,6 +841,23 @@ class SimpleMQTTServer: print_block["home_flag"] = print_block.get("home_flag", 0) | 0x100 # bit 8 = HAS_SDCARD_NORMAL print_block["sdcard"] = True print_block.setdefault("storage", {"free": 1_000_000_000, "total": 32_000_000_000}) + # Live-progress fields the slicer's Send pre-flight reads + # (#1558). When the real target printer is mid-print, the + # cached push_status carries the real values for these + # fields and the slicer reads the VP as "busy" — refusing + # Send — even though gcode_state above is forced to IDLE. + # For VP usage the VP isn't actually running the print + # the printer is, so these need to mirror the synthetic + # stub's idle values. Same shape as #1228 (storage) — the + # cached-branch override set just needed extending. + print_block["mc_print_stage"] = "" + print_block["mc_percent"] = 0 + print_block["mc_remaining_time"] = 0 + print_block["stg"] = [] + print_block["stg_cur"] = 0 + print_block["layer_num"] = 0 + print_block["total_layer_num"] = 0 + print_block["print_error"] = 0 status = {"print": print_block} await self._publish_to_report(writer, status, serial or self.serial) return @@ -1047,6 +1211,10 @@ class SimpleMQTTServer: # Forward anything the synthetic flow didn't handle to the real # printer. AMS load / dry / xcam / system / extrusion_cali_get etc. if not handled_locally and self._bridge is not None and self._bridge.is_active: + # Remember which client originated this command so the + # printer's response goes back only to them (not fanned + # out to every connected slicer). + self._record_pending_request(data, client_id) self._bridge.forward_to_printer(data) except (IndexError, ValueError, OSError) as e: diff --git a/backend/app/services/virtual_printer/ssdp_server.py b/backend/app/services/virtual_printer/ssdp_server.py index eb0a17fac..9ee1d6b1d 100644 --- a/backend/app/services/virtual_printer/ssdp_server.py +++ b/backend/app/services/virtual_printer/ssdp_server.py @@ -55,6 +55,9 @@ class VirtualPrinterSSDPServer: self.model = model self._bind_ip = bind_ip self._running = False + # Set after the primary multicast socket is bound — see ftp_server.py + # for rationale. + self.ready = asyncio.Event() self._socket: socket.socket | None = None self._extra_sockets: list[socket.socket] = [] self._extra_interfaces = extra_interfaces or [] @@ -169,6 +172,7 @@ class VirtualPrinterSSDPServer: local_ip = self._get_local_ip() logger.info("SSDP server listening on port %s, advertising IP: %s", SSDP_PORT, local_ip) logger.info("Virtual printer: %s (%s) model=%s", self.name, self.serial, self.model) + self.ready.set() # Create extra sockets for additional interfaces (VPN, etc.) # If no explicit extra interfaces given and we're bound to a @@ -249,6 +253,7 @@ class VirtualPrinterSSDPServer: """Stop the SSDP server.""" logger.info("Stopping SSDP server") self._running = False + self.ready.clear() await self._cleanup() async def _cleanup(self) -> None: diff --git a/backend/app/services/virtual_printer/tailscale.py b/backend/app/services/virtual_printer/tailscale.py index 4f665db0a..b1571a86b 100644 --- a/backend/app/services/virtual_printer/tailscale.py +++ b/backend/app/services/virtual_printer/tailscale.py @@ -134,13 +134,18 @@ class TailscaleService: try: returncode, stdout, stderr = await self._run_tailscale("status", "--json", timeout=5.0) - except OSError as e: + except (OSError, asyncio.TimeoutError) as e: + # asyncio.TimeoutError covers the case where ``_run_tailscale`` + # killed a stuck subprocess and re-raised. Without this branch + # the timeout escaped into the FastAPI route handler and could + # crash the VP management UI for users with a lagging + # tailscaled daemon. return TailscaleStatus( available=False, hostname="", tailnet_name="", fqdn="", - error=str(e), + error=str(e) or "tailscale status timed out", ) if returncode is None or returncode != 0: diff --git a/backend/app/services/virtual_printer/tcp_proxy.py b/backend/app/services/virtual_printer/tcp_proxy.py index 4684e0685..500138d19 100644 --- a/backend/app/services/virtual_printer/tcp_proxy.py +++ b/backend/app/services/virtual_printer/tcp_proxy.py @@ -814,7 +814,17 @@ class FTPTLSProxy(TLSProxy): async def stop(self) -> None: """Stop proxy and clean up data connection servers.""" - # Close all data servers first + # Cancel any pending auto_close timeouts so they don't outlive the + # proxy holding a dead server reference. Without this, up to 101 + # tasks lingered for ~60 s after stop(), each gripping a server + # ref + an active_connections slot; under rapid mode-switch the + # ports stayed bound long enough to fail the next start(). + for task in list(self._auto_close_tasks): + task.cancel() + if self._auto_close_tasks: + await asyncio.gather(*self._auto_close_tasks, return_exceptions=True) + self._auto_close_tasks.clear() + # Close all data servers for server in list(self._data_servers): try: server.close() @@ -827,6 +837,7 @@ class FTPTLSProxy(TLSProxy): async def start(self) -> None: """Start the FTP TLS proxy.""" self._data_servers: list[asyncio.Server] = [] + self._auto_close_tasks: list[asyncio.Task] = [] await super().start() async def _handle_client( @@ -1408,7 +1419,11 @@ class FTPTLSProxy(TLSProxy): if server in self._data_servers: self._data_servers.remove(server) - asyncio.create_task(auto_close(), name=f"ftp_data_timeout_{port}") + # Track the auto_close task so stop() can cancel it; otherwise the + # 60 s timeout would hold a server reference past proxy teardown. + ac_task = asyncio.create_task(auto_close(), name=f"ftp_data_timeout_{port}") + self._auto_close_tasks.append(ac_task) + ac_task.add_done_callback(lambda t, tasks=self._auto_close_tasks: tasks.remove(t) if t in tasks else None) logger.debug("FTP data proxy: port %s → %s:%s", port, printer_ip, printer_port) @@ -1469,6 +1484,17 @@ class SlicerProxyManager: self._bind_server = None self._probe_servers: list[asyncio.Server] = [] self._tasks: list[asyncio.Task] = [] + # Pre-bind the lifetime-coupled collections so ``stop()`` works if + # called before ``start()`` finishes (rapid mode-switch races). + # Previously ``_ftp_data_proxies`` was first assigned inside ``start()`` + # at line ~1520, so an early stop hit AttributeError and left + # sockets stranded. + self._ftp_data_proxies: list[TCPProxy] = [] + # Actual FTP listen port — class constant by default; ``start()`` + # overwrites with the redirect target when iptables-redirect is + # active. ``get_status()`` reads this so diagnostics probe the + # port that actually has a listener, not the static LOCAL_FTP_PORT. + self._actual_ftp_port: int = self.LOCAL_FTP_PORT # FTP passive data port range — Bambu printers typically use ports in # this range for EPSV/PASV data connections. We pre-listen on all of @@ -1499,8 +1525,25 @@ class SlicerProxyManager: redirect_target, ) ftp_listen_port = redirect_target + # Cache the actual listen port for get_status() / diagnostic so the + # port_ftps check probes the port that actually has a socket. + self._actual_ftp_port = ftp_listen_port - # FTP control — raw TCP pass-through (end-to-end TLS with printer) + # FTP control — raw TCP pass-through (end-to-end TLS with printer). + # A TLS 1.3 → 1.2 ClientHello-rewrite was attempted to work around + # BambuStudio's libcurl bug on the X1C FTPS data channel (PSK + # session-resumption + CURLE_PARTIAL_FILE). Reverted because the + # rewrite broke the control-channel TLS handshake itself: replacing + # 0x0304 with a duplicate 0x0303 in supported_versions while leaving + # the TLS-1.3-only extensions (key_share, psk_key_exchange_modes, + # signature_algorithms_cert) in place produced a malformed ClientHello + # that the printer or slicer rejected, and the connection closed + # before any data channel was opened. A proper fix needs full TLS + # bumping (terminate + re-establish) with packet-capture work + # that's out of scope for now. X1C proxy-mode FTP uploads remain + # broken — users with X1C should use the non-proxy modes (immediate + # / review / print_queue) which work end-to-end via the VP's own + # FTP server on TLS 1.2. self._ftp_proxy = TCPProxy( name="FTP", listen_port=ftp_listen_port, @@ -1745,8 +1788,16 @@ class SlicerProxyManager: await dp.stop() self._ftp_data_proxies = [] + # Probe servers need wait_closed — without it the OS releases the + # bind socket asynchronously and a rapid stop+start cycle (e.g. + # config-change-driven mode switch) can race "address already in + # use" on the probe ports. for srv in self._probe_servers: - srv.close() + try: + srv.close() + await srv.wait_closed() + except OSError: + pass # Best-effort — port may already be released self._probe_servers = [] # Cancel tasks @@ -1798,7 +1849,14 @@ class SlicerProxyManager: return { "running": self.is_running, "target_host": self.target_host, - "ftp_port": self.LOCAL_FTP_PORT, + # ``_actual_ftp_port`` reflects the iptables-redirected listen + # port when the docker-host deployment uses + # ``iptables -t nat -A PREROUTING ... REDIRECT --to-port`` to + # let non-root containers serve on the printer's 990. Returning + # the class constant here made the diagnostic probe a port + # nothing was listening on and report a false fail on every + # working redirect deployment. + "ftp_port": self._actual_ftp_port, "mqtt_port": self.LOCAL_MQTT_PORT, "bind_ports": self.PRINTER_BIND_PORTS, "ftp_connections": (len(self._ftp_proxy._active_connections) if self._ftp_proxy else 0), diff --git a/backend/tests/unit/services/test_virtual_printer.py b/backend/tests/unit/services/test_virtual_printer.py index 3643b7de9..0c30e292f 100644 --- a/backend/tests/unit/services/test_virtual_printer.py +++ b/backend/tests/unit/services/test_virtual_printer.py @@ -1030,6 +1030,220 @@ class TestVirtualPrinterInstance: kwargs = archive_print_mock.await_args.kwargs assert kwargs.get("prefer_filename_for_name") is expected_prefer_filename + # ======================================================================== + # Tests for failure-path cleanup (#audit-R2-1) + # ======================================================================== + # + # All three file handlers (_archive_file, _queue_file, _add_to_print_queue) + # previously only popped _pending_files and unlinked the temp file on the + # success branch. Failure paths leaked the marker (blocking same-name + # retries via the FTP layer) and the temp file on disk. The cleanup must + # ALWAYS run, even when archival / queue insert raises. + + @pytest.mark.asyncio + async def test_archive_file_failure_path_pops_pending_and_unlinks(self, tmp_path): + """When the archive layer raises, `_pending_files[filename]` must still + be popped and the temp file must be unlinked. Otherwise the FTP layer's + same-name retry guard would silently reject the slicer's next attempt + and the upload_dir would accumulate ghost files.""" + from backend.app.services.virtual_printer.manager import VirtualPrinterInstance + + mock_db = AsyncMock() + mock_session_factory = MagicMock() + mock_session_ctx = AsyncMock() + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) + mock_session_ctx.__aexit__ = AsyncMock(return_value=False) + mock_session_factory.return_value = mock_session_ctx + + inst = VirtualPrinterInstance( + vp_id=40, + name="ArchiveFailCleanup", + mode="immediate", + model="C12", + access_code="12345678", + serial_suffix="391800040", + base_dir=tmp_path, + session_factory=mock_session_factory, + ) + file_path = tmp_path / "cleanup-archive.3mf" + file_path.write_bytes(b"fake3mf") + inst._pending_files[file_path.name] = file_path + + with ( + patch( + "backend.app.api.routes.settings.get_setting", + new_callable=AsyncMock, + return_value=None, + ), + patch( + "backend.app.services.archive.ArchiveService.archive_print", + new_callable=AsyncMock, + side_effect=RuntimeError("archive blew up"), + ), + ): + await inst._archive_file(file_path, "192.168.1.100") + + assert file_path.name not in inst._pending_files + assert not file_path.exists() + + @pytest.mark.asyncio + async def test_queue_file_failure_path_pops_pending_and_unlinks(self, tmp_path): + """Same invariant for _queue_file: a DB error during PendingUpload + insert must not leak the in-flight marker or the temp file.""" + from backend.app.services.virtual_printer.manager import VirtualPrinterInstance + + mock_db = AsyncMock() + # Commit raises — emulating a DB connectivity error. + mock_db.add = MagicMock() + mock_db.commit = AsyncMock(side_effect=RuntimeError("db unreachable")) + mock_session_factory = MagicMock() + mock_session_ctx = AsyncMock() + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) + mock_session_ctx.__aexit__ = AsyncMock(return_value=False) + mock_session_factory.return_value = mock_session_ctx + + inst = VirtualPrinterInstance( + vp_id=41, + name="QueueFailCleanup", + mode="review", + model="C12", + access_code="12345678", + serial_suffix="391800041", + base_dir=tmp_path, + session_factory=mock_session_factory, + ) + file_path = tmp_path / "cleanup-queue.3mf" + file_path.write_bytes(b"fake3mf") + inst._pending_files[file_path.name] = file_path + + await inst._queue_file(file_path, "192.168.1.100") + + assert file_path.name not in inst._pending_files + assert not file_path.exists() + + @pytest.mark.asyncio + async def test_add_to_print_queue_failure_path_pops_pending_and_unlinks(self, tmp_path): + """Same invariant for _add_to_print_queue: a DB error or archive + failure must not leak the in-flight marker or the temp file.""" + from backend.app.services.virtual_printer.manager import VirtualPrinterInstance + + mock_db = AsyncMock() + mock_db.add = MagicMock() + mock_db.commit = AsyncMock() + mock_db.execute = AsyncMock(side_effect=RuntimeError("queue insert blew up")) + mock_session_factory = MagicMock() + mock_session_ctx = AsyncMock() + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) + mock_session_ctx.__aexit__ = AsyncMock(return_value=False) + mock_session_factory.return_value = mock_session_ctx + + inst = VirtualPrinterInstance( + vp_id=42, + name="DispatchFailCleanup", + mode="print_queue", + model="C12", + access_code="12345678", + serial_suffix="391800042", + auto_dispatch=True, + base_dir=tmp_path, + session_factory=mock_session_factory, + ) + file_path = tmp_path / "cleanup-dispatch.3mf" + file_path.write_bytes(b"fake3mf") + inst._pending_files[file_path.name] = file_path + + with patch( + "backend.app.api.routes.settings.get_setting", + new_callable=AsyncMock, + return_value=None, + ): + await inst._add_to_print_queue(file_path, "192.168.1.100") + + assert file_path.name not in inst._pending_files + assert not file_path.exists() + + # ======================================================================== + # Test for position=MAX+1 (audit-R2) + # ======================================================================== + + @pytest.mark.asyncio + async def test_add_to_print_queue_position_picks_max_plus_one(self, tmp_path): + """VP-queue items previously got hardcoded `position=1`, colliding + with existing items at position 1 and producing non-deterministic + execution order. Now the position is chosen by `MAX(position)+1` + against the target queue, matching the canonical `POST /print-queue/` + path.""" + from backend.app.services.virtual_printer.manager import VirtualPrinterInstance + + # Capture the inserted PrintQueueItem so we can assert on .position. + added_items: list = [] + + class _RecordingDb: + def __init__(self): + self.add = lambda item: added_items.append(item) + self.commit = AsyncMock() + + async def execute(self, query): # noqa: ARG002 + """Return a stub result whose `.scalar()` reports the existing + MAX(position) for the target. Returning 7 means the new item + should land at 8.""" + result = MagicMock() + result.scalar = MagicMock(return_value=7) + return result + + mock_db = _RecordingDb() + mock_session_factory = MagicMock() + mock_session_ctx = AsyncMock() + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) + mock_session_ctx.__aexit__ = AsyncMock(return_value=False) + mock_session_factory.return_value = mock_session_ctx + + inst = VirtualPrinterInstance( + vp_id=43, + name="PositionMaxPlusOne", + mode="print_queue", + model="C12", + access_code="12345678", + serial_suffix="391800043", + target_printer_id=99, + auto_dispatch=True, + base_dir=tmp_path, + session_factory=mock_session_factory, + ) + file_path = tmp_path / "next-position.3mf" + file_path.write_bytes(b"fake3mf") + + mock_archive = MagicMock() + mock_archive.id = 555 + mock_archive.printer_id = None + mock_archive.filename = "next-position.3mf" + mock_archive.print_name = "next-position" + mock_archive.status = "archived" + + with ( + patch( + "backend.app.api.routes.settings.get_setting", + new_callable=AsyncMock, + return_value=None, + ), + patch( + "backend.app.services.archive.ArchiveService.archive_print", + new_callable=AsyncMock, + return_value=mock_archive, + ), + patch( + "backend.app.core.websocket.ws_manager.send_archive_created", + new_callable=AsyncMock, + ), + ): + await inst._add_to_print_queue(file_path, "192.168.1.100") + + # One queue item was added. + assert len(added_items) == 1 + queue_item = added_items[0] + # Position = max(7) + 1 = 8 — NOT the legacy hardcoded 1. + assert queue_item.position == 8 + class TestVirtualPrinterManager: """Tests for VirtualPrinterManager orchestrator.""" @@ -1203,6 +1417,7 @@ class TestVirtualPrinterManager: "target_printer_id": None, "auto_dispatch": True, "tailscale_disabled": True, # Opt-in default (#1070 UX fix) + "queue_force_color_match": False, # default — must be explicit so MagicMock truthiness doesn't trip the change detector "position": 0, } defaults.update(overrides) @@ -2391,11 +2606,34 @@ class TestBindServer: base_dir=tmp_path, ) + # Each mocked child service exposes a real asyncio.Event for the + # readiness barrier added in start_server (set on instantiation so + # the barrier returns immediately in tests). + ready_event = asyncio.Event() + ready_event.set() + + def with_ready(*_args, **_kwargs): + child = MagicMock() + child.ready = ready_event + return child + with ( - patch("backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer"), - patch("backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer"), - patch("backend.app.services.virtual_printer.manager.SimpleMQTTServer"), - patch("backend.app.services.virtual_printer.manager.BindServer") as mock_bind_cls, + patch( + "backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer", + side_effect=with_ready, + ), + patch( + "backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer", + side_effect=with_ready, + ), + patch( + "backend.app.services.virtual_printer.manager.SimpleMQTTServer", + side_effect=with_ready, + ), + patch( + "backend.app.services.virtual_printer.manager.BindServer", + side_effect=with_ready, + ) as mock_bind_cls, patch.object(inst._cert_service, "delete_printer_certificate"), patch.object( inst._cert_service, diff --git a/backend/tests/unit/test_vp_certificate_rotation.py b/backend/tests/unit/test_vp_certificate_rotation.py new file mode 100644 index 000000000..9f3c75469 --- /dev/null +++ b/backend/tests/unit/test_vp_certificate_rotation.py @@ -0,0 +1,76 @@ +"""Tests for CertificateService.ensure_certificates' CA-rotation guard. + +When the shared CA is regenerated (e.g. its expiry crossed +``CA_EXPIRY_THRESHOLD_DAYS``), any per-VP printer certificate that was +signed by the OLD CA becomes orphaned: it still exists on disk and the +old fallback ``cert_path.exists()`` check would happily reuse it. A +slicer that imported the NEW CA then fails the TLS handshake because +the printer cert's issuer doesn't match anything in its trust store. + +``_cert_matches_current_ca`` is the guard. It compares the on-disk +printer cert's issuer against the on-disk CA cert's subject; on +mismatch ``ensure_certificates`` regenerates the per-VP cert under the +current CA. +""" + +from backend.app.services.virtual_printer.certificate import CertificateService + + +def test_ensure_certificates_reuses_cert_when_issuer_matches_ca(tmp_path): + """Happy path: a freshly-generated CA + per-VP cert pair shares + issuer/subject. ``ensure_certificates`` reads them back without + regenerating.""" + svc = CertificateService(cert_dir=tmp_path, serial="01P00A391800001") + + # First call: generates the CA + per-VP cert from scratch. + first_cert, first_key = svc.ensure_certificates() + first_cert_bytes = first_cert.read_bytes() + + # Second call: cert + CA exist and the issuer matches. Should reuse. + second_cert, _ = svc.ensure_certificates() + assert second_cert.read_bytes() == first_cert_bytes + + +def test_ensure_certificates_regenerates_when_ca_rotated(tmp_path): + """CA rotation scenario: the CA file is replaced with a different one + (e.g. previous expired and was regenerated). The per-VP cert on disk + was signed by the old CA, so its issuer no longer matches the new CA's + subject. ``ensure_certificates`` must regenerate the per-VP cert.""" + # Build the first pair. + svc1 = CertificateService(cert_dir=tmp_path, serial="01P00A391800001") + orig_cert_bytes = svc1.ensure_certificates()[0].read_bytes() + orig_ca_bytes = svc1.ca_cert_path.read_bytes() + + # Simulate CA rotation: build a SECOND CA in a different dir, then + # swap that CA's files into the original CA path. The per-VP cert + # still on disk was signed by the original CA — issuer mismatch now. + rotated_dir = tmp_path / "rotated" + rotated_dir.mkdir() + svc_rotated = CertificateService(cert_dir=rotated_dir, serial="01P00A391800002") + svc_rotated.ensure_certificates() + + # Overwrite the original CA on disk with the rotated one. + svc1.ca_cert_path.write_bytes(svc_rotated.ca_cert_path.read_bytes()) + svc1.ca_key_path.write_bytes(svc_rotated.ca_key_path.read_bytes()) + assert svc1.ca_cert_path.read_bytes() != orig_ca_bytes # confirm rotation + + # Build a fresh service against the rotated CA, then ensure_certificates + # should detect the mismatch and regenerate the per-VP cert. + svc2 = CertificateService(cert_dir=tmp_path, serial="01P00A391800001") + new_cert, _ = svc2.ensure_certificates() + new_cert_bytes = new_cert.read_bytes() + + # New per-VP cert must differ from the old (signed by a different CA now). + assert new_cert_bytes != orig_cert_bytes + + +def test_cert_matches_current_ca_returns_false_when_no_ca(tmp_path): + """If the CA file is missing entirely, the match check must return + False so ``ensure_certificates`` falls through to ``generate_certificates`` + instead of returning a per-VP cert that nothing can validate.""" + svc = CertificateService(cert_dir=tmp_path, serial="01P00A391800001") + # Write a per-VP cert without a CA. + svc.cert_path.write_bytes(b"fake cert content") + svc.key_path.write_bytes(b"fake key content") + # No bbl_ca.crt on disk → match fails safely. + assert svc._cert_matches_current_ca() is False diff --git a/backend/tests/unit/test_vp_delete_cleanup.py b/backend/tests/unit/test_vp_delete_cleanup.py new file mode 100644 index 000000000..931692a8a --- /dev/null +++ b/backend/tests/unit/test_vp_delete_cleanup.py @@ -0,0 +1,137 @@ +"""Tests for DELETE /virtual-printers/{vp_id} orphan cleanup. + +Before the fix, deleting a VP only stopped the running instance and +removed the row. The on-disk ``base_dir/uploads//`` directory +lingered, and any ``PendingUpload`` rows that pointed into it remained +in ``pending`` status — showing up as phantom entries in +``/pending-uploads/``. The route now (a) marks those rows as +``discarded`` and (b) ``shutil.rmtree``s the upload_dir after the DB +commit succeeds. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from backend.app.api.routes.virtual_printers import delete_virtual_printer + + +@pytest.mark.asyncio +async def test_delete_vp_marks_orphan_pending_uploads_discarded(tmp_path): + """A VP with PendingUpload rows pointing at its upload_dir: after + DELETE, those rows must be flipped to ``discarded`` and the on-disk + directory must be gone.""" + vp_id = 77 + upload_dir = tmp_path / "uploads" / str(vp_id) + upload_dir.mkdir(parents=True) + (upload_dir / "stale.3mf").write_bytes(b"orphaned content") + + # Build PendingUpload-like mocks. The route mutates `.status`. + pending_a = MagicMock() + pending_a.file_path = str(upload_dir / "stale.3mf") + pending_a.status = "pending" + pending_b = MagicMock() + pending_b.file_path = str(upload_dir / "another.3mf") + pending_b.status = "pending" + + # Unrelated PendingUpload that does NOT belong to this VP — must + # be left alone. + other_pending = MagicMock() + other_pending.file_path = str(tmp_path / "uploads" / "99" / "not-mine.3mf") + other_pending.status = "pending" + + # Mock VP row. + vp_row = MagicMock() + vp_row.id = vp_id + vp_row.name = "DeleteMe" + + # Mock DB session with the route's two .execute() calls + flush + commit. + select_calls = {"i": 0} + + async def fake_execute(query): # noqa: ARG001 + """Return the VP row on the first call (vp lookup) and the + in-range PendingUpload rows on the second call (orphan query). + Third call is the DELETE which doesn't need a result.""" + select_calls["i"] += 1 + result = MagicMock() + if select_calls["i"] == 1: + result.scalar_one_or_none = MagicMock(return_value=vp_row) + elif select_calls["i"] == 2: + scalars = MagicMock() + scalars.all = MagicMock(return_value=[pending_a, pending_b]) + result.scalars = MagicMock(return_value=scalars) + return result + + db = AsyncMock() + db.execute = fake_execute + db.flush = AsyncMock() + db.commit = AsyncMock() + + # Mock the manager: remove_instance, _base_dir, sync_from_db. + fake_manager = MagicMock() + fake_manager.remove_instance = AsyncMock() + fake_manager.sync_from_db = AsyncMock() + fake_manager._base_dir = tmp_path + + with patch( + "backend.app.services.virtual_printer.virtual_printer_manager", + fake_manager, + ): + await delete_virtual_printer(vp_id=vp_id, db=db, _=None) + + # Both in-range PendingUpload rows must be flipped to "discarded". + assert pending_a.status == "discarded" + assert pending_b.status == "discarded" + # The unrelated row was never returned from the query — left alone. + assert other_pending.status == "pending" + + # The on-disk upload_dir is gone. + assert not upload_dir.exists() + + # The running instance was stopped before the row was removed. + fake_manager.remove_instance.assert_awaited_once_with(vp_id) + + +@pytest.mark.asyncio +async def test_delete_vp_with_no_orphan_uploads_still_succeeds(tmp_path): + """A VP with no PendingUpload rows and no upload_dir on disk: the + cleanup path must be a clean no-op, not raise.""" + vp_id = 88 + + vp_row = MagicMock() + vp_row.id = vp_id + vp_row.name = "EmptyDelete" + + select_calls = {"i": 0} + + async def fake_execute(query): # noqa: ARG001 + select_calls["i"] += 1 + result = MagicMock() + if select_calls["i"] == 1: + result.scalar_one_or_none = MagicMock(return_value=vp_row) + elif select_calls["i"] == 2: + # No PendingUpload rows match. + scalars = MagicMock() + scalars.all = MagicMock(return_value=[]) + result.scalars = MagicMock(return_value=scalars) + return result + + db = AsyncMock() + db.execute = fake_execute + db.flush = AsyncMock() + db.commit = AsyncMock() + + fake_manager = MagicMock() + fake_manager.remove_instance = AsyncMock() + fake_manager.sync_from_db = AsyncMock() + fake_manager._base_dir = tmp_path # no uploads/ exists + + with patch( + "backend.app.services.virtual_printer.virtual_printer_manager", + fake_manager, + ): + await delete_virtual_printer(vp_id=vp_id, db=db, _=None) + + fake_manager.remove_instance.assert_awaited_once_with(vp_id) + # No directory to remove — and we didn't crash trying to. + assert not (tmp_path / "uploads" / str(vp_id)).exists() diff --git a/backend/tests/unit/test_vp_ftp_stor.py b/backend/tests/unit/test_vp_ftp_stor.py new file mode 100644 index 000000000..41ccd8b66 --- /dev/null +++ b/backend/tests/unit/test_vp_ftp_stor.py @@ -0,0 +1,143 @@ +"""Tests for the FTPSession.cmd_STOR streaming + size-cap behaviour. + +The original cmd_STOR buffered the entire upload in a ``list[bytes]`` and +called ``write_bytes`` at the end. For multi-GB ``.gcode.3mf`` files this +peaked at ~2× the file size in RSS (chunks held + the ``b''.join`` of +them) and could OOM low-memory hosts. The streaming rewrite writes each +chunk to disk inline (memory bounded at one chunk) and enforces +``MAX_UPLOAD_BYTES``. These tests pin both behaviours without standing +up a real TLS/FTP server. +""" + +import asyncio +import ssl +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from backend.app.services.virtual_printer.ftp_server import MAX_UPLOAD_BYTES, FTPSession + + +def _make_session(tmp_path, *, data_chunks: list[bytes]) -> FTPSession: + """Build an FTPSession primed with a pre-fed StreamReader so cmd_STOR + can iterate through the chunks without a real TCP connection. + """ + control_writer = MagicMock() + control_writer.write = MagicMock() + control_writer.drain = AsyncMock() + control_writer.get_extra_info = MagicMock(return_value=("192.168.1.99", 12345)) + + upload_dir = tmp_path / "uploads" + upload_dir.mkdir(parents=True, exist_ok=True) + + session = FTPSession( + reader=asyncio.StreamReader(), + writer=control_writer, + upload_dir=upload_dir, + access_code="deadbeef", + ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER), + on_file_received=None, + bind_address="127.0.0.1", + vp_name="stor-test", + ) + session.authenticated = True + + data_reader = asyncio.StreamReader() + for chunk in data_chunks: + data_reader.feed_data(chunk) + data_reader.feed_eof() + session._data_reader = data_reader + + data_writer = MagicMock() + data_writer.close = MagicMock() + data_writer.wait_closed = AsyncMock() + session._data_writer = data_writer + + session._data_connected.set() + session.data_server = None + + return session + + +@pytest.mark.asyncio +async def test_stor_writes_payload_to_disk(tmp_path): + """Happy path: chunks fed to the data reader land in the upload_dir + with the right content + the slicer gets 226.""" + payload = b"X" * (3 * 64 * 1024 + 123) # 3 chunks + a partial one + chunks = [payload[i : i + 65536] for i in range(0, len(payload), 65536)] + session = _make_session(tmp_path, data_chunks=chunks) + session.send = AsyncMock() + + await session.cmd_STOR("Untitled.gcode.3mf") + + saved = session.upload_dir / "Untitled.gcode.3mf" + assert saved.exists() + assert saved.stat().st_size == len(payload) + assert saved.read_bytes() == payload + + sent_codes = [args[0][0] for args in session.send.call_args_list] + assert 150 in sent_codes # "Opening data connection" + assert 226 in sent_codes # "Transfer complete" + + +@pytest.mark.asyncio +async def test_stor_rejects_upload_over_max_upload_bytes(tmp_path, monkeypatch): + """A single chunk taking us over the cap must abort with 426 and + drop the partially-written file so it doesn't masquerade as a + successful upload.""" + # Lower the cap to 100 KiB so the test doesn't need to allocate + # 4 GiB to trigger it. The same logic governs the production cap. + monkeypatch.setattr( + "backend.app.services.virtual_printer.ftp_server.MAX_UPLOAD_BYTES", + 100 * 1024, + ) + + over_cap = b"X" * (200 * 1024) # 200 KiB > 100 KiB cap + session = _make_session(tmp_path, data_chunks=[over_cap]) + session.send = AsyncMock() + + await session.cmd_STOR("toobig.gcode.3mf") + + # Partial file must be unlinked. + assert not (session.upload_dir / "toobig.gcode.3mf").exists() + # 426 (transfer failed) sent — not 226. + sent_codes = [args[0][0] for args in session.send.call_args_list] + assert 426 in sent_codes + assert 226 not in sent_codes + + +@pytest.mark.asyncio +async def test_stor_cleans_up_partial_file_on_read_error(tmp_path): + """If the data channel raises mid-transfer (slicer RST, TLS error, + timeout, …), the partial file on disk must be removed so the next + upload of the same name starts clean and the user doesn't see a + truncated file in the upload_dir.""" + payload = b"X" * 65536 # one full chunk + session = _make_session(tmp_path, data_chunks=[payload]) + session.send = AsyncMock() + + # Inject an OSError on the NEXT read after the first chunk. + orig_read = session._data_reader.read + state = {"calls": 0} + + async def read_then_error(n): + state["calls"] += 1 + if state["calls"] == 1: + return await orig_read(n) + raise OSError("simulated connection reset") + + session._data_reader.read = read_then_error # type: ignore[assignment] + + await session.cmd_STOR("aborted.gcode.3mf") + + # Partial file removed. + assert not (session.upload_dir / "aborted.gcode.3mf").exists() + sent_codes = [args[0][0] for args in session.send.call_args_list] + assert 426 in sent_codes + + +def test_max_upload_bytes_is_at_least_4_gib(): + """The cap exists to prevent OOM, but should be high enough that + legitimate multi-plate .gcode.3mf uploads (~hundreds of MB) succeed + without bumping up against it. 4 GiB is the documented floor.""" + assert MAX_UPLOAD_BYTES >= 4 * 1024 * 1024 * 1024 diff --git a/backend/tests/unit/test_vp_mqtt_bridge.py b/backend/tests/unit/test_vp_mqtt_bridge.py index 8e355e3dc..0e98e8689 100644 --- a/backend/tests/unit/test_vp_mqtt_bridge.py +++ b/backend/tests/unit/test_vp_mqtt_bridge.py @@ -774,6 +774,48 @@ class TestStatusReportCachedAsBase: assert payload["print"]["gcode_state"] == "PREPARE" assert payload["print"]["gcode_file"] == "foo.3mf" + @pytest.mark.asyncio + async def test_live_progress_fields_zeroed_in_cached_branch(self): + """#1558: when the real target printer is mid-print, the cached + push_status carries live values for mc_percent / stg_cur / layer_num / + etc. BambuStudio's Send pre-flight reads any of these as "VP busy" + even when gcode_state above is forced to IDLE — blocking Send while + the target prints. The cached branch must override these to the same + idle values the synthetic stub uses. + """ + server = _make_server() + bridge = MagicMock() + # Real printer mid-print state: gcode_state may be RUNNING upstream, + # but the VP's own _gcode_state is IDLE (Send is requesting a + # new upload, the VP isn't running anything). + bridge.get_latest_print_state.return_value = { + "command": "push_status", + "msg": 0, + "gcode_state": "RUNNING", + "mc_print_stage": "2", + "mc_percent": 47, + "mc_remaining_time": 3600, + "stg": [1, 2, 3], + "stg_cur": 14, + "layer_num": 120, + "total_layer_num": 250, + "print_error": 0, + } + server.set_bridge(bridge) + published = self._capture_published(server) + + await server._send_status_report(MagicMock()) + _serial, payload = published[0] + # Every live-progress field must reflect "idle / VP isn't busy". + assert payload["print"]["mc_print_stage"] == "" + assert payload["print"]["mc_percent"] == 0 + assert payload["print"]["mc_remaining_time"] == 0 + assert payload["print"]["stg"] == [] + assert payload["print"]["stg_cur"] == 0 + assert payload["print"]["layer_num"] == 0 + assert payload["print"]["total_layer_num"] == 0 + assert payload["print"]["print_error"] == 0 + # --------------------------------------------------------------------------- # Wire format diff --git a/backend/tests/unit/test_vp_mqtt_server.py b/backend/tests/unit/test_vp_mqtt_server.py index 12a70d654..5a6717db9 100644 --- a/backend/tests/unit/test_vp_mqtt_server.py +++ b/backend/tests/unit/test_vp_mqtt_server.py @@ -399,3 +399,162 @@ class TestHandleClientHonoursKeepalive: # Exit was via DISCONNECT at ~2.5s, NOT a 3s keepalive timeout. # Allow generous slop. assert 2.0 < elapsed < 3.0, f"expected exit on DISCONNECT near 2.5s, got {elapsed:.2f}s" + + +class TestAuthRateLimit: + """Per-IP rate-limiting of MQTT CONNECT auth attempts. + + Bambuddy's VP exposes an 8-char access code via the slicer-facing MQTT + server. Without a rate-limit the code is brute-forceable by anyone who + can reach the VP's bind IP (LAN or VPN). The limiter records each + failed auth attempt per source IP and rejects further CONNECTs from + that IP once the per-window threshold is crossed, then auto-recovers + when the window expires. Verified here against the production + constants imported from the module. + """ + + @pytest.fixture + def server(self): + from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer + + return _make_server(serial="01P00A391800002") + + def test_under_limit_attempts_are_allowed(self, server): + from backend.app.services.virtual_printer.mqtt_server import _AUTH_RATE_LIMIT_MAX_ATTEMPTS + + ip = "192.168.1.50" + # Record (max-1) failures and verify the next attempt is still allowed. + for _ in range(_AUTH_RATE_LIMIT_MAX_ATTEMPTS - 1): + server._record_auth_failure(ip) + assert server._is_auth_rate_limited(ip) is False + + def test_exactly_max_attempts_triggers_rate_limit(self, server): + from backend.app.services.virtual_printer.mqtt_server import _AUTH_RATE_LIMIT_MAX_ATTEMPTS + + ip = "192.168.1.50" + for _ in range(_AUTH_RATE_LIMIT_MAX_ATTEMPTS): + server._record_auth_failure(ip) + # At exactly the cap, further attempts must be rejected. + assert server._is_auth_rate_limited(ip) is True + + def test_window_recovery_clears_old_failures(self, server): + """A burst of failures older than the window must NOT count + against the IP — the limiter is sliding, not cumulative.""" + import time as _time + + from backend.app.services.virtual_printer.mqtt_server import ( + _AUTH_RATE_LIMIT_MAX_ATTEMPTS, + _AUTH_RATE_LIMIT_WINDOW_SECONDS, + ) + + ip = "192.168.1.50" + # Inject stale timestamps directly — older than the window means the + # limiter should drop them on the next probe. + stale = _time.monotonic() - _AUTH_RATE_LIMIT_WINDOW_SECONDS - 1.0 + server._auth_failures[ip] = [stale] * _AUTH_RATE_LIMIT_MAX_ATTEMPTS + # All recorded failures are outside the window — IP is no longer rate-limited. + assert server._is_auth_rate_limited(ip) is False + # And the dict entry was pruned (empty) instead of leaking forever. + assert ip not in server._auth_failures + + def test_multiple_ips_tracked_independently(self, server): + from backend.app.services.virtual_printer.mqtt_server import _AUTH_RATE_LIMIT_MAX_ATTEMPTS + + # One IP exhausts the budget; another IP must still be allowed. + for _ in range(_AUTH_RATE_LIMIT_MAX_ATTEMPTS): + server._record_auth_failure("10.0.0.1") + assert server._is_auth_rate_limited("10.0.0.1") is True + assert server._is_auth_rate_limited("10.0.0.2") is False + + def test_successful_auth_clears_failure_history(self, server): + """A successful auth must wipe the IP's prior-failures stash so the + user isn't penalised for typos that they ultimately corrected.""" + from backend.app.services.virtual_printer.mqtt_server import _AUTH_RATE_LIMIT_MAX_ATTEMPTS + + ip = "192.168.1.50" + # Build up failures one short of the cap. + for _ in range(_AUTH_RATE_LIMIT_MAX_ATTEMPTS - 1): + server._record_auth_failure(ip) + # Successful auth must clear them. + server._clear_auth_failures(ip) + # Now a subsequent failure starts the count over at 1 (well under cap). + server._record_auth_failure(ip) + assert server._is_auth_rate_limited(ip) is False + + +class TestPendingRequestRouting: + """`push_raw_to_clients` routes the printer's response back only to the + slicer that originated the request, not to every connected slicer. + + The bridge calls `push_raw_to_clients(topic, payload)` for every + response it sees from the real printer. Before the fix, this fanned + out to every connected slicer — leaking slicer A's + `extrusion_cali_get` response into slicer B's command stream. The + fix records `sequence_id → client_id` on the way out and looks it + back up on the way in. + """ + + @pytest.fixture + def server(self): + return _make_server(serial="01P00A391800003") + + def test_single_slicer_routes_to_that_slicer(self, server): + """Sanity check: when one slicer is connected, the response goes + to it regardless of whether the seq_id was recorded.""" + # No recorded request, no slicer seen → returns None (broadcast). + assert server._lookup_pending_request_client(b'{"print": {"sequence_id": "999"}}') is None + + def test_record_pending_request_walks_nested_blocks(self, server): + """The slicer wraps its sequence_id under whichever subsystem the + command targets (`print`, `info`, `system`, …). The helper must + find it regardless of which key it's nested under.""" + server._record_pending_request( + {"print": {"command": "extrusion_cali_get", "sequence_id": "42"}}, + "clientA", + ) + assert server._pending_requests.get("42") == "clientA" + + server._record_pending_request( + {"info": {"command": "get_version", "sequence_id": "43"}}, + "clientB", + ) + assert server._pending_requests.get("43") == "clientB" + + def test_lookup_pops_entry_so_each_response_routes_once(self, server): + """Once a response is matched, the pending entry is consumed so + a later coincidental sequence_id from a printer-initiated push + doesn't mis-route to the original client.""" + server._record_pending_request({"print": {"sequence_id": "100"}}, "clientA") + # First lookup finds it… + assert server._lookup_pending_request_client(b'{"print": {"sequence_id": "100"}}') == "clientA" + # …and removes it. Second lookup with the same seq returns None + # (treated as printer-initiated → broadcast fallback). + assert server._lookup_pending_request_client(b'{"print": {"sequence_id": "100"}}') is None + + def test_fifo_eviction_when_cache_fills(self, server): + """If a slicer sends many commands without responses (or the + responses never arrive), the oldest entries age out so the dict + can't grow unbounded.""" + from backend.app.services.virtual_printer.mqtt_server import _PENDING_REQUEST_MAX_ENTRIES + + # Fill the dict to one over the cap. + for i in range(_PENDING_REQUEST_MAX_ENTRIES + 1): + server._record_pending_request({"print": {"sequence_id": str(i)}}, "clientA") + # The dict is capped — the oldest entry ("0") is gone, the newest is in. + assert len(server._pending_requests) <= _PENDING_REQUEST_MAX_ENTRIES + assert "0" not in server._pending_requests + assert str(_PENDING_REQUEST_MAX_ENTRIES) in server._pending_requests + + def test_response_without_recorded_seq_returns_none_for_broadcast(self, server): + """Printer-initiated pushes (push_status etc.) have a sequence_id + the bridge never saw recorded. ``_lookup_pending_request_client`` + must return None so ``push_raw_to_clients`` falls back to fan-out + — every slicer expects to receive these unsolicited messages.""" + # No record for this seq id. + assert server._lookup_pending_request_client(b'{"print": {"sequence_id": "777"}}') is None + + def test_malformed_payload_falls_through_to_broadcast(self, server): + """A non-JSON / non-dict payload must NOT crash the routing path — + return None so the response broadcasts.""" + assert server._lookup_pending_request_client(b"not valid json") is None + assert server._lookup_pending_request_client(b'"a string, not a dict"') is None diff --git a/docker-compose.yml b/docker-compose.yml index 90d7e9d35..16abc25db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,7 +34,7 @@ services: # - "6000:6000" # Virtual printer file transfer tunnel # - "322:322" # Virtual printer RTSP camera (X1/H2/P2; proxy mode + non-proxy modes with a target printer) # - "2024-2026:2024-2026" # Virtual printer proprietary ports (A1/P1S) - # - "50000-50100:50000-50100" # Virtual printer FTP passive data + # - "50000-51000:50000-51000" # Virtual printer FTP passive data (widened from 50000-50100 for multi-VP headroom) volumes: - bambuddy_data:/app/data - bambuddy_logs:/app/logs diff --git a/install/docker-install.ps1 b/install/docker-install.ps1 index 5a2a45f0d..176de208f 100644 --- a/install/docker-install.ps1 +++ b/install/docker-install.ps1 @@ -240,7 +240,7 @@ function Update-ComposeForDockerDesktop { [System.IO.File]::WriteAllText($path, $content) Warn 'Printer auto-discovery (SSDP) does NOT work on Docker Desktop. Add printers manually by IP.' - Warn 'Virtual Printer ports (322, 990, 2024-2026, 3000/3002, 6000, 8883, 50000-50100) stay commented out.' + Warn 'Virtual Printer ports (322, 990, 2024-2026, 3000/3002, 6000, 8883, 50000-51000) stay commented out.' Warn 'If you plan to use a Virtual Printer, edit docker-compose.yml and uncomment the relevant `- "PORT:PORT"` lines under `ports:`.' }