` 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/