From a46b120336d6071d4fe9e2e05fffe11b8f8cedf3 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 28 Jun 2026 13:40:52 +0200 Subject: [PATCH] Housekeeping --- BACKERS.md | 2 + CHANGELOG.md | 188 ++++++++++--------------------------- README.md | 14 +++ backend/app/core/config.py | 2 +- 4 files changed, 65 insertions(+), 141 deletions(-) diff --git a/BACKERS.md b/BACKERS.md index 71a189da7..6509c43a3 100644 --- a/BACKERS.md +++ b/BACKERS.md @@ -50,6 +50,8 @@ If you sponsor and your name isn't here within 48h, please write an email to mar - [@andyspinball](https://github.com/andyspinball - [@avandeputte](https://github.com/avandeputte) - [@joeferrante](https://github.com/joeferrante) +- [@GPop61](https://github.com) +- [@CooleyMcCoolson](https://github.com/CooleyMcCoolson) --- ## One-time and historical supporters diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e7d37800..0c8ae6f18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,152 +2,60 @@ All notable changes to Bambuddy will be documented in this file. -## [0.2.4.7] - 2026-06-14 - -### Fixed -- **Assign-spool picker note now visible on mobile (#793 follow-up, reporter @EmcetPL)** — The original fix for #793 added the spool note as an HTML `title=` tooltip on each picker button in `AssignSpoolModal.tsx` (lines 417 + 492). `title=` only surfaces on hover, which doesn't exist on touch devices — a phone user tapping a card just selects it, the note never appears. Users who store their tracking ID in the note field were blind on mobile. **Fix.** Render the note as a small muted truncated line directly under the weight on both the internal-inventory branch and the Spoolman branch: `text-[10px] text-bambu-gray/70 mt-1 truncate`, kept inside the `truthy &&` guard so empty notes don't add a blank row. The existing `title={spool.note}` is preserved on the new `

` element so desktop hover and mobile-browser long-press still surface the full untruncated text for notes that overflow the truncate. Keeps the 2-col mobile grid density unchanged (one extra `text-[10px]` line is ~12 px), no new state, no popover/modal, no new touch target. Mirrored across both branches per the inventory-parity rule so internal and Spoolman pickers stay shape-equal. Frontend `npm run build` clean. `npx vitest run AssignSpoolModal.test.tsx AssignToAmsModal.test.tsx` 23/23 green. **Scope.** No backend change. No new permission. No new i18n key (the note text is user-authored, not translatable). - -- **API keys with Manage Library permission can now rename / delete / move library files (#1832, reporter @MorganMLGman)** — `require_ownership_permission` gates API keys on `all_perm` only (line 1668) — the comment block at line 1659 says OWN and ALL "both map to the same scope flag" for queue / archives / etc., so checking `all_perm` is the correct gate. Library deliberately broke that invariant by putting `LIBRARY_UPDATE_ALL` / `LIBRARY_DELETE_ALL` in `_APIKEY_DENIED_PERMISSIONS` while only the OWN variants were allowlisted under `can_manage_library`. Net effect: every library curation route (DELETE `/library/files/{id}`, PUT `/library/files/{id}` rename, POST `/library/files/move`) returned `403 "API keys cannot be used for administrative operations"` for keys with `can_manage_library=True`, contradicting the wiki docs that explicitly list "rename and delete your own library entries" under that scope. Only `POST /library/files/{id}/slice` worked (it doesn't go through `require_ownership_permission`). The "ALL stays admin-only because it crosses the user boundary" comment was internally inconsistent: API keys have no per-row ownership identity (`user=None`), so the route's `file.created_by_id != user.id` ownership check would `AttributeError` on a key acting under OWN anyway — the only path that ever worked was `can_modify_all=True`, which `all_perm` denial blocked outright. **Fix.** Fold `LIBRARY_UPDATE_ALL` and `LIBRARY_DELETE_ALL` into `_APIKEY_SCOPE_BY_PERMISSION` mapping to `can_manage_library` (matching the `can_queue` precedent — both `QUEUE_UPDATE_OWN` and `QUEUE_UPDATE_ALL` map to `can_queue` for the same per-key-identity reason). Remove both from `_APIKEY_DENIED_PERMISSIONS`. `LIBRARY_PURGE` deliberately stays denied — it bypasses the soft-delete window and is genuinely destructive, the kind of cross-boundary op the denylist exists for. **Tests.** 5 new cases in `TestLibraryPermissions` pinning the route-level contract — `test_apikey_with_manage_library_can_delete_file`, `test_apikey_with_manage_library_can_rename_file`, `test_apikey_with_manage_library_can_move_file`, `test_apikey_without_manage_library_still_blocked` (regression guard that the fix widens the allowed-permission set, not the per-key scope check), and `test_apikey_with_manage_library_still_cannot_purge` (LIBRARY_PURGE stays admin-only). The matrix drift-detection in `test_auth_apikey_rbac.py` updated to include `LIBRARY_UPDATE_OWN`, `LIBRARY_UPDATE_ALL`, `LIBRARY_DELETE_ALL` under `can_manage_library` and removes `LIBRARY_DELETE_ALL` from `_ADMIN_CASES`. `pytest -n 30 backend/tests/unit backend/tests/integration` green (6494). Ruff clean. **Scope.** No DB migration. No schema change. No frontend change. The wiki entry for API key permissions at `/features/api-keys/#available-permissions` now matches actual behaviour. - -- **HMS Action buttons now reach the printer (#1830, H2D/H2C wrong-plate verification)** — The HMS Actions feature shipped in #1743 looked correct at the publish layer but the firmware silently dropped the commands at the printer, so clicking "Stop printing", "Problem solved and resume", or "Ignore and resume" did nothing visible on the live H2D — the modal kept reappearing, the print stayed paused, and the route still returned `200 OK`. Three independent bugs combined into one user-facing failure. **(1) Wrong command shape for resume / stop.** `hms_resume()` and `hms_stop()` sent the documented-but-not-actually-used `{"err": , "param": "reserve", "job_id": , ...}` shape that BambuStudio never produces. Bambu firmware rejects this silently — verified by injecting candidate shapes on `device//request` against a live H2D paused on a wrong-plate HMS: the `err`-bearing shape held PAUSE → PAUSE for the full window, the plain `{"print":{"command":"stop","param":"","sequence_id":"0"}}` transitioned PAUSE → FAILED in 1.7s, the same plain `resume` transitioned PAUSE → RUNNING in <2s. Fix: both helpers send the plain shape now, no `err`, no `job_id`, no `param:"reserve"`. **(2) `IGNORE_RESUME` mapped to the wrong command for paused prints.** The original mapping dispatched `idle_ignore` for both `IGNORE_RESUME` and `NO_REMINDER_NEXT_TIME`. `idle_ignore` is BambuStudio's "dismiss this warning" command and only works for non-pause warnings — verified against the H2D, idle_ignore on a paused print is silently rejected regardless of `err`. `hms_ignore()` now branches on `self.state.gcode_state == "PAUSE"`: paused → dispatch plain `resume` (which is what the button actually means on a paused print), running/idle → keep `idle_ignore` with the `type=0/1` persistence flag. `DONT_REMIND_NEXT_TIME` on PAUSE degrades to resume too — the "don't remind" flag can't ride along on a resume but the user's clicked-action intent (continue printing) is honoured. **(3) 64-bit `hms[]`-array faults truncated to a non-matching `err` (#1830 §(1)).** The hms[] parser at line 2740 built the short code as `f"{(attr >> 16) & 0xFFFF:04X}_{code & 0xFFFF:04X}"`, discarding 32 of the 64 bits of the fault identifier. For codes whose full form is e.g. `0C00_0300_0002_000C`, the truncated `0C00000C` doesn't match what the firmware compares against in `idle_ignore`. New `HMSError.full_code` field carries the canonical hex identifier — 16 chars `f"{attr:08X}{code:08X}"` for hms[]-sourced faults, 8 chars `f"{print_error:08X}"` for print_error-sourced faults (which are already 32-bit). Catalog lookup tries the 16-char form first and falls back to the 8-char short code so existing entries keep matching. Frontend echoes `error.full_code` back as `HmsActionBody.print_error` instead of recomputing the short code; the schema's pattern relaxes to `^[0-9A-Fa-f]{8}([0-9A-Fa-f]{8})?$` to accept both lengths. **(4) Masking failure — publish-success returned as printer-ack (#1830 §(3)).** `execute_hms_action` returned True the moment the publish succeeded, so any of the three bugs above produced `200 OK` while the printer ignored the command and the modal kept popping. The `/hms/execute-action` route now snapshots `(gcode_state, print_error, hms_errors count)` before dispatch, awaits `HMS_ACTION_ACK_WAIT_SECONDS` (default 2.5s, module-level so tests override), and returns `502 "Printer did not acknowledge HMS action within 2.5s"` if none of those moved. Every accepted HMS action mutates at least one of the three, so this is a clean signal. **Empirical verification.** A test harness on `device/0948BB540200427/request` confirmed each shape against the live H2D: a print sent with deliberately-wrong build plate raises `print_error=0x05008051` ("Detected build plate is not the same as the Gcode file"), the printer enters `gcode_state=PAUSE`, and the new command shapes transition out correctly. The current Bambuddy code (before this fix) failed to act on every button. **Tests.** `test_hms_actions.py` shape assertions rewritten — `test_resume_is_plain_no_err_no_job_id`, `test_stop_is_plain_no_err_no_job_id`, `test_ignore_resume_dispatches_resume_when_print_paused`, `test_ignore_resume_uses_idle_ignore_when_not_paused`, `test_dont_remind_dispatches_resume_when_paused`, `test_dont_remind_uses_idle_ignore_type_one_when_not_paused`, `test_idle_ignore_accepts_16_char_full_code`. New `TestHMSFullCode` class in `test_bambu_mqtt.py` pins the parser contract — `test_hms_array_path_populates_16_char_full_code`, `test_print_error_path_populates_8_char_full_code`, `test_hms_array_catalog_lookup_tries_16_char_first`, `test_hms_array_catalog_falls_back_to_8_char`. New integration cases in `test_printers_api.py` — `test_execute_hms_action_no_printer_ack_returns_502`, `test_execute_hms_action_accepts_16_char_full_code`. The malformed-input test now covers 9- and 15-char rejections (the relaxed pattern accepts 8 OR 16, nothing in between). `pytest -n 30 backend/tests/unit/services/test_hms_actions.py backend/tests/unit/services/test_bambu_mqtt.py backend/tests/unit/services/test_printer_manager.py backend/tests/integration/test_printers_api.py` green (509 + 181). `ruff check` clean. Frontend `npm run build` clean. **Scope.** No DB migration. No new permission. No new i18n key — the frontend toast on action failure already uses the existing `hmsErrors.actionFailed` string, which now gets the more accurate "Printer did not acknowledge" message instead of "Failed to send action". The `HMSError.full_code` field defaults to `""` so old in-memory state surviving a backend upgrade (without an MQTT reconnect) degrades to the existing 8-char short code via the frontend's `||` fallback. +## [0.2.4.8] - 2026-06-28 ### Added -- **Sponsor-prompt thresholds lowered to fire for typical new installs** — The in-app sponsor toast in `useSponsorPrompt` was calibrated for power users: the lowest print milestone was `100`, the lowest archive milestone was `50`, the lowest filament-cost milestone was `100`. A check of recent Matomo data showed the toast firing very rarely (`?from=app-toast-prints-100` = 4 visits, `?from=app-toast-archives-50` = 3 visits in a 7-day window) — most installs simply never reach those bars, especially with the install base ~doubling since March. Calibration widened: `PRINT_MILESTONES` now `(10, 25, 100, 500, 1000, 2500, 5000)`, `ARCHIVE_MILESTONES` now `(5, 10, 50, 250, 1000)`, `COST_MILESTONES` now `(25, 50, 100, 500, 1000)`. The existing priority order (anniversary → prints → archives → cost → version-update) and 14-day cross-family cooldown are unchanged, so a user still sees at most one toast per fortnight. The "fire highest unseen milestone" logic in `_check_prints` / `_check_archives` / `_check_cost` is unchanged — a user already at 200 prints still gets `prints-100` first (they crossed it earlier in the timeline). The existing toast copy uses `{count}` / `{total}` interpolation in all 11 locales — no new i18n keys needed; "You've completed 10 prints with Bambuddy" reads as fluently as the 100 variant. **Tests.** `test_failed_prints_dont_count` and `test_fires_when_cost_sum_crosses_100` rebalanced (5 completed prints instead of 50; 5 prints × 21 cost-each instead of 30 × 3.5) so they still test "below the lowest threshold" semantics with the new lower bars. New `test_fires_at_lowest_threshold` pins `prints-10` as the new minimum trigger. `pytest -n 30 backend/tests/unit/test_sponsor_prompt_service.py backend/tests/integration/test_sponsor_prompt_api.py` green (25/25). `ruff check` clean. **Scope.** No DB migration. No new permission. No frontend change. The change is opt-in by virtue of the existing toast cooldown — installs that already saw a recent toast see no behaviour change; installs that never crossed the old 100-print bar become eligible the first time they pass 10 prints (subject to the 14-day cooldown after any other family fires first). +- **Lower sponsor-prompt thresholds so the toast fires for typical new installs** (`257b9e2c`) +- **SSO autologin + disable local username/password login (#1589, requested by @einstux)** (`549d3216`) +- **"Auto-add unknown RFID spools" toggle + global confirmation modal (#1764)** (`9f9c1775`) +- **Backup-aware filament deficit check, colour-strict (#1762)** (`29a5abd9`) +- **Drag-reorder for grouped queue items; collapsed batches no longer block adjacent rows** (`7b06ebd7`) +- **Per-filament humidity threshold for auto-drying + alarms (#1605, requested by @thenewguy)** (`7e5eff14`) +- **Per-printer Maintenance Mode toggle (#1476, requested by @IndividualGhost1905 / Ferdi SEVER)** (`ee270922`) +- **In-app sponsor-toast at earned milestones** (`e761e092`) +- **Prominent sponsor banner on Settings → General** (`5c16ef3e`) +- **Heater history (nozzle / bed / chamber) tracked + per-tile chart-icon overlay opens history modal** (`d1d16659`) +- **AMS Filament Backup status badge + toggle on the printer card; "Prefer lowest" actually picks the lowest spool (#1766, reported by @biduleman)** (`99c6949b`) +- **Updated printer card UI for structure and readability (#1661)** (`6fa74be4`) -- **Autologin via SSO + disable local login (#1589, requested by @einstux)** — Two related additions for operators who run their own OIDC SSO and want exactly one auth path. **Global setting `local_login_enabled`** (default True, preserves pre-#1589 behaviour) — when False, `POST /api/v1/auth/login` rejects username + password credentials with HTTP 401 (same wording as wrong-password to avoid leaking "local disabled" to credential-stuffing tools), `POST /api/v1/auth/forgot-password` rejects with HTTP 403 (the reset wouldn't grant access anyway), and the LoginPage hides the credentials form + Forgot Password link, leaving only the OIDC provider buttons. **Env-var recovery path** `BAMBUDDY_LOCAL_LOGIN=true` (also accepts `1` / `yes`, case-insensitive) bypasses the gate on both routes and flips the reported `local_login_enabled` flag on `/auth/advanced-auth/status` back to True so the LoginPage matches what the route actually accepts — a server admin whose SSO provider is unreachable can recover the install with one env var, no DB editing. LDAP keeps its own `ldap_enabled` switch and is not affected by this gate — a delegated directory has its own policy and lockouts and is closer to SSO than to local credentials. **Per-OIDC-provider `is_autologin` flag** — when set on an enabled provider, the LoginPage redirects unauthenticated visitors directly to that provider's authorize URL on mount instead of rendering the login form. At most one provider can carry the flag at a time (app-layer invariant enforced in both create and update routes: setting it on one provider clears it on every other). **Two-layer fallback for autologin** — the LoginPage races `getOIDCAuthorizeUrl` against a 5-second timeout; on success the browser navigates to the IdP, on timeout or fetch error the redirect is aborted, the page renders normally, and a sticky amber banner explains "Autologin to failed, pick a provider". A bookmarkable `/login?fallback=local` query param always skips the autologin redirect — paired with the `BAMBUDDY_LOCAL_LOGIN=true` env-var on the server, this is the documented "SSO is broken, let me back in" path. **Two safety refusals on disabling local login**: settings PUT returns HTTP 400 ("no OIDC provider is enabled") when no enabled OIDC provider exists, and HTTP 400 ("you would lock yourself out") when the calling admin has no `UserOIDCLink` row. Either failure mode would otherwise lock everyone out of the install. **Backend.** `local_login_enabled: bool = True` added to `AppSettings` + `AppSettingsUpdate` schemas and to the `_BOOL_KEYS` allowlist in `routes/settings.py`. `OIDCProvider.is_autologin: bool` column via `_safe_execute(ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT ...)` — SQLite `DEFAULT 0`, Postgres `DEFAULT false` per the project's existing boolean-migration pattern. New `OIDCProviderResponse.is_autologin` field threaded through `from_attributes=True`. `_local_login_env_bypass()` reads at call time (not import time) so tests can monkeypatch the env between cases. `/auth/advanced-auth/status` extended with `local_login_enabled` and `autologin_provider_id` so the LoginPage decides UI in one query — `autologin_provider_id` filters on `is_enabled=True AND is_autologin=True` so disabling a provider stops the autologin redirect even if the flag stays set. **Frontend.** `LoginPage.tsx` adds the autologin `useEffect` (skips redirect when `?fallback=local` is in the URL, when an OIDC token is already in the fragment, or when an `oidc_error` query param is present from a previous round trip), the autologin-failed banner, and a "Local sign-in disabled" notice that replaces the form when the flag is off. `SettingsPage.tsx` exposes the `local_login_enabled` toggle in the OIDC tab card above the existing provider list; `OIDCProviderSettings.tsx` adds the per-provider Autologin toggle in the form's flags row. `AppSettings`, `AdvancedAuthStatus`, `OIDCProvider`, and `OIDCProviderCreate` TypeScript interfaces extended to match. **i18n.** 6 new keys (`login.autologinFailed`, `login.localDisabledNotice`, `settings.localLogin.disable`, `settings.localLogin.disableHint`, `settings.oidc.form.autologin`, `settings.oidc.form.autologinDesc`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5375 leaves per locale, no English fallback. **Tests.** 6 new integration cases in `test_local_login_gate.py`: login default allows local, login rejected when flag off and no env bypass (with generic 401 wording asserted), env-var bypasses the gate, forgot-password rejected when flag off, status surfaces both new fields, env bypass flips the reported flag back to True. Full nearby suites green: `test_auth_api.py` 44/44, `test_mfa_api.py + test_oidc_relogin.py + test_settings_ui_preferences.py` 159/159. Backend `ruff check` clean. Frontend `npm run build` clean. **Scope.** No new permission — the existing `SETTINGS_UPDATE` permission gates the toggle. The migration is a single `ADD COLUMN` per backend; the `local_login_enabled` setting lives in the existing settings key-value table and needs no migration. Default behaviour is unchanged: fresh installs and upgrades see no difference until an admin explicitly enables the toggle or sets a provider as autologin. +### Changed +- **Printer card AMS row: external tray height matches regular AMS slots** (`00e4aed7`) -- **Printer card AMS row: external tray height matches regular AMS slots** — On dual-nozzle printers (H2C / H2D) the External card carried an extra `Ext-L` / `Ext-R` caption underneath each tray to disambiguate which extruder it fed. That caption added one text line of vertical height to the External card only, so the entire bottom row of the printer card's AMS panel (External alongside AMS-C / HT-A) was visibly taller than the row above it (AMS-A / AMS-B). Fix: the L/R distinction now lives **inside** the slot's colour circle in place of the 1-based slot index (so the left external tray reads `L`, the right reads `R`), and the bottom caption is removed. Single-nozzle externals — a single tray with no left/right distinction — keep the `1` index. The `FilamentSlotCircle` `slotNumber` prop is widened from `number` to `number | string` to carry the L/R label; the two regular-AMS callsites that pass a numeric index keep working unchanged. The `Ext-L` / `Ext-R` strings are still used as the slot's "location" label in the filament hover card (so context is preserved when hovering for details) — just not as a separate caption on the visible row. Frontend `npm run build` clean. Existing 10 `FilamentSlotCircle` tests stay green (the new optional string accept-shape is backward-compatible). - -- **Cam Wall: don't kill shared streams when one viewer closes + offline tiles show OFF, not LIVE** — Two small but load-bearing fixes against the new cam-wall view. **(1) Offline tile chip.** A disconnected printer (`status.connected === false`) was still assigned `live` mode by `CameraWall.modeByPrinter` — it consumed a `Max live streams` budget slot AND rendered the red `LIVE` chip on top of the `WifiOff` placeholder. The allocator now treats `!connected` like off-screen — assigns `paused`, leaves the live budget intact. The existing `CameraTile` rendering (`WifiOff` icon, dark `Off` chip) takes over automatically. Side effect: an 8-printer wall with 2 offline X1Cs no longer wastes 2 of the 4 default live slots on dead tiles. **(2) Shared-broadcaster teardown.** `/api/v1/printers/{id}/camera/stop` is the unmount cleanup for every camera consumer (`CameraTile`, `EmbeddedCameraViewer`, popup `CameraPage`). It used to unconditionally `shutdown_broadcaster(f"printer-{id}")` + kill every ffmpeg in `_active_streams` whose key starts with `{printer_id}-`. The fan-out broadcaster is shared across all viewers of the same printer, so closing the embedded viewer while the cam-wall tile of the same printer was visible force-killed the source the tile was pulling from — the tile's `` errored out and showed `No signal` until the user navigated away. The broadcaster itself already has correct natural-shutdown semantics: each subscriber's HTTP teardown calls `unsubscribe(queue)`, and when the count reaches 0 the broadcaster's own `_grace_then_stop` waits `_GRACE_SECONDS` (5 s) before tearing down — re-checking under the lock so a new subscriber rejoining cancels the shutdown. `/camera/stop` was just a fast-cleanup shortcut for the single-viewer case. **Fix.** New `get_subscriber_count(key)` accessor in `camera_fanout.py` exposes the broadcaster's `subscriber_count` (the private list-len already used internally). The `/camera/stop` route now reads `get_subscriber_count(f"printer-{printer_id}")` BEFORE the force-teardown; when ≥ 1 subscriber is still attached, it returns `{"stopped": 0, "skipped": true}` early and leaves the broadcaster + ffmpeg processes alone. The leaving viewer's HTTP teardown still runs the natural `iter_subscriber.finally → unsubscribe` path, so its subscription is correctly released; the broadcaster keeps serving the other viewer(s). Single-viewer close still hits the force-teardown path immediately (no subscribers remain at all). Cost: in the race where the leaving viewer's HTTP teardown has already propagated to the broadcaster at the moment its `/camera/stop` POST lands (count just dropped to 0), force-teardown still runs and we miss the optimization for a different actually-still-subscribed viewer — but the natural grace-shutdown bounds the worst case at 5 s of ffmpeg tail, not a stuck stream. Verified by inspection: this race only matters when subscriber_count transitions through 0 between the HTTP teardown and the POST, which requires both viewers' tabs to close in lockstep — practically unobservable. **Tests.** New `test_stop_camera_stream_skips_shutdown_when_subscribers_remain` in `test_camera_api.py` patches `get_subscriber_count` to return 2 and asserts `/camera/stop` returns `{stopped: 0, skipped: true}`, does NOT call `shutdown_broadcaster`, and does NOT terminate any `_active_streams` ffmpeg process. The existing 6 stop-route tests stay green because they don't pre-populate subscribers — `get_subscriber_count` returns 0, the early-return doesn't trigger, and the existing force-teardown still runs. Full `test_camera_api.py` 43/43 green. `ruff check backend/` clean. Frontend `npm run build` clean. **Scope.** No API contract change — the existing `{"stopped": int}` shape is preserved, the new `"skipped"` field is additive. No new permission. No DB migration. No i18n change. - -- **Cam Wall: per-tile print/printer status overlay** — Cam-wall tiles now surface live printer state on top of the camera image instead of being a pure video grid. A new gear-menu toggle `Status overlay` switches between `Off`, `Compact`, and `Full` (default `Full`). **Compact** paints a colour-coded state chip in the top-left corner — `Printing` / `Paused` / `Finished` / `Error` — bucketed using the same `classifyPrinterStatus` rules that drive the printer-card badges, with `Idle` deliberately suppressed so a wall of cold printers stays visually quiet. **Full** adds a bottom info strip on tiles whose state is `Printing` or `Paused`: the active file's `subtask_name ?? gcode_file`, the rounded progress percent, `Layer N/M` when both are known, and the remaining time formatted by the existing `formatDuration(remaining_time * 60)` helper from `utils/date.ts` — so the numbers match what the printer card shows for the same printer. When the printer's known HMS errors are non-empty (filtered via the existing `filterKnownHMSErrors` from `HMSErrorModal`), the chip flips to the red `Error` colour with a `lucide-react` `AlertTriangle` icon inline. The whole overlay layer is gated by `connected` — disconnected and paused-mode tiles render the existing offline / paused placeholders unchanged. **Zero new network cost.** `CameraWall.tsx` already ran `useQueries({ queryKey: ['printerStatus', id], ... })` against every printer for the connected flag; the patch widens the `useMemo` to expose the full `PrinterStatus` payload and threads `state`, `progress`, `remaining_time`, `layer_num`, `total_layers`, `subtask_name`, `gcode_file`, and the filtered HMS error count into each `CameraTile` — same shared React Query cache the `PrinterCard` flow populates, so Cards ↔ Cam Wall flips remain instant and the wall opens no second status fan-out. **Settings.** Per-user, persisted in `localStorage` under `camWallStatusMode` alongside the existing `camWallMaxLive` and `camWallSnapshotSec` keys. The picker is a three-segment button row inside the existing cam-wall settings popover (gear icon, click-outside dismiss), labelled `Off` / `Compact` / `Full`. Default `Full` because the cards already show this info — users who pick cam-wall view still want to glance the same details without flipping back. **CameraTile contract.** All new props (`statusMode`, `printerState`, `progress`, `remainingMin`, `layerNum`, `totalLayers`, `printName`, `hmsErrorCount`) are optional with safe defaults, so the 5 existing vitest cases in `CameraTile.test.tsx` continue to pass unmodified — the status layer is purely additive on the leaf component. The state-bucket classifier lives co-located in `CameraTile.tsx` (mirrors `PrintersPage.classifyPrinterStatus` for `RUNNING/PAUSE/FINISH/FAILED`) so the tile renders correctly even if called outside the cam-wall scheduler. **Temperatures intentionally not surfaced.** Nozzle / bed / chamber readouts would crowd the tile and overlap the existing top-right LIVE/SNAP/OFF mode indicator and bottom-edge printer name; the printer card remains the canonical surface for those. **i18n.** 7 new keys under `printers.camWall` (`layer`, `timeLeft`, `statusMode.{off,compact,full}`, `settings.statusOverlay`, `settings.statusOverlayHint`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) — no English fallback. State chip labels reuse the existing `printers.status.{printing,paused,finished,error,idle}` keys so no new translation work was needed for the bucket vocabulary. Parity script `check-i18n-parity.mjs` adds two legitimate-cognate exceptions: `Compact` for French (same word) and `Off` for Italian (universal loanword); both remain real translations in every other locale. Parity check 5388 leaves per locale. **Scope.** No backend change. No new request. No new permission. No DB migration. The toggle defaults to `Full`, so installs see the overlay the first time they open Cam Wall — flipping to `Off` reverts to the original camera-only behaviour. - -- **Cam Wall view on the Printers page** — New view toggle next to the card-size selector flips the entire printers list into a responsive grid of live camera tiles (`Cards` ↔ `Cam wall`). Reuses the existing per-printer FTP / RTSPS proxy on `/api/v1/printers/{id}/camera/stream`, so the backend ffmpeg fan-out is the same one EmbeddedCameraViewer already drives — no new server-side state machine. Bandwidth ceiling matters on the RPi installs ([[bambuddy-install-base-2026-06-20]] documents that the median deployment is a Pi 4): each live tile is one TLS pull + one MJPEG fan-out. To stay sustainable on a Pi 4 with 8+ printers, only the tiles currently on-screen are live, and only up to `Max live streams` (default 4) at any moment — everything else falls back to per-tile snapshot polling against `/api/v1/printers/{id}/camera/snapshot` at a configurable interval (default 8 s). Tiles that scroll off-screen pause entirely. **Architecture.** `frontend/src/components/CameraTile.tsx` is the leaf — three modes (`live` / `snapshot` / `paused`), a single `` element with `loading="lazy"`, an `onError` no-signal fallback, and a `useEffect` cleanup that POSTs `/camera/stop` (with `keepalive: true`) on mode-out-of-live AND on unmount so the backend releases the transcoder slot. Same `/camera/stop` discipline EmbeddedCameraViewer uses, so a tile that scrolls off the wall is byte-identical to closing a floating viewer. `frontend/src/components/CameraWall.tsx` is the scheduler — an `IntersectionObserver` (threshold 0.4 to avoid flicker at scroll boundaries) tracks visibility, then a `useMemo` walks the printer list in sort order and assigns the first N visible tiles to `live`, the rest of the visible set to `snapshot`, and off-screen tiles to `paused`. The walker is stable on a given render (no LRU eviction churn) which avoids the "tile flickers between live and snapshot every frame" failure mode. Reuses the same `['printerStatus', id]` React Query cache each `PrinterCard` already populates, so flipping between Cards and Cam Wall is instant and the wall doesn't open a second status fetch fan-out. Clicking a tile honours the existing `Settings → camera_view_mode` preference — opens the floating `EmbeddedCameraViewer` when set to `embedded`, otherwise pops the `/camera/:id` window with the saved size/position from `cameraWindowState`. **Settings.** Both knobs are per-user, persisted in `localStorage` (`camWallMaxLive`, `camWallSnapshotSec`) — not a global backend setting, since a Pi 4 user and a NUC user looking at the same install want different caps. Bounded `[1, 16]` for max live and `[2, 60]` seconds for snapshot interval, both rendered as an inline gear-icon popover above the grid with click-outside dismiss. The Cam Wall button is permission-gated on `camera:view`; viewers without the permission see it disabled. The card-size selector goes opacity-40 + pointer-events-none in cam-wall mode (tile size is governed by the responsive grid, not the cardSize knob). **i18n.** 13 new keys (`printers.pageView.cards`, `printers.pageView.camWall`, `printers.camWall.{noPrinters,noSignal,live,snap,off,summary}`, `printers.camWall.settings.{title,maxLive,maxLiveHint,snapshotInterval,snapshotIntervalHint}`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) — no English fallback. Parity check 5369 leaves per locale. **Tests.** 5 new vitest cases in `frontend/src/__tests__/components/CameraTile.test.tsx` cover live URL emission with `fps=8`, snapshot URL emission with the cache-bust counter advancing on the interval, offline placeholder for disconnected printers, paused placeholder rendering, and the `/camera/stop` POST firing when the tile transitions out of live. **Scope.** No backend change. No DB migration. No new permission. The existing `EmbeddedCameraViewer` is untouched — Cam Wall is purely additive. The `printerPageView` toggle defaults to `cards`, so installs see no behaviour change until a user picks Cam Wall. - -- **AMS drying badge now shows the active cycle's filament + target temperature** — During an active drying cycle the AMS card on the printers page renders `Drying · PETG @ 65°C · 11h 35m left` (the loaded-filament line under the slots) instead of the bare `Drying · 11h 35m left`. Bambu's per-tick AMS push only carries the `dry_time` countdown — the chosen filament name and target temperature are never echoed on the wire, so the badge had no source of truth for them. `BambuMQTTClient.send_drying_command(mode=1, ...)` now caches `{ams_id: {filament, temp}}` on the client; the cache is cleared on `mode=0` and on the per-AMS `dry_time` falling-edge to 0 (same detector that drives the smart-plug-after-drying callback). `PrinterManager.get_drying_targets(printer_id)` exposes it, `printer_state_to_dict` and `routes/printers.py::get_printer_status` thread it onto each AMS dict as `dry_target_temp` + `dry_filament`, the AMS schema gains both fields, and the AMS-HT compact badge gets the same render. Falls back to the first loaded tray's `tray_type` + RFID-recommended `drying_temp` when no cached target (drying started before backend launch, backend restarted mid-cycle, or cycle started from another source) — the same heuristic the popover already uses to seed defaults. New i18n key `printers.drying.targetSummary` = `{{filament}} @ {{temp}}°C`, translated in all 11 locales (parity check 5356 leaves per locale). 5 new backend tests in `TestSupportsDryingCommand` (cache populated on mode=1, overwrite on second start, cleared on mode=0, per-AMS isolation across stop) and 4 new tests in `TestDryingTargetExposure` (cached target wins over fallback, fallback derives from loaded tray, both fields None when no cache + empty trays, targets don't leak across AMS ids). **Note about Bambu's printer display.** A user reported that with PLA loaded in AMS-A slot 1 and a Bambuddy-initiated PETG @ 65°C drying cycle, the H2D's own screen showed "PLA" — Bambuddy's wire payload was confirmed correct via journalctl (`filament: "PETG"` sent, `result: success, filament: PETG, temp: 65` ACKed back). The display behaviour is the Bambu firmware labelling the active cycle by the loaded tray's filament rather than the `filament` field of the command. This Bambuddy change makes our own UI reflect what we actually sent, independent of the firmware's display choice. - -- **Continue auto-drying while a print is running on capable hardware** — Bambu shipped "Print While Drying" firmware-side on H2D (01.03.00.00+), H2C / H2S / P2S / H2D Pro (01.02.00.00+), X2D / A2L (01.01.00.00+), and X1C (01.11.02.00+). The existing Queue Auto-Drying loop only fires on idle printers — when a print starts, drying stops or never starts, even though the spools may still be wet. New **Settings → Print Queue → "Continue drying while printing"** toggle (default OFF) lets the same scheduler evaluator also run on the *busy* printer set. Backend: `supports_drying_while_printing(model, firmware)` in `printer_manager.py` is a strict allowlist verified against Bambu's wiki release-notes phrasing ("printing while filament is drying" / "Print While Drying" — every matrix-confirmed model carries that wording verbatim; **P1P / P1S / A1 / A1 Mini / X1 (non-C) / X1E are intentionally excluded** because the wiki is silent for them, and on those models the firmware would reject the command anyway via `dry_sf_reason=[0]` (TaskOccupied)). The capability is gated on both display names (`"H2D"`, `"X1C"`, ...) and internal SSDP / MQTT model codes (`"O1D"`, `"O1E"`, `"O2D"`, `"O1C"`, `"O1C2"`, `"O1S"`, `"N6"`, `"BL-P001"`, `"N7"`, `"N9"`) — the printer's `model` field can carry either, the existing `supports_drying` precedent uses both. `_check_auto_drying` in `print_scheduler.py` now resolves model + firmware up front for every printer and computes `mid_print = busy AND toggle_on AND supports_drying_while_printing`; when `mid_print` is True the busy-skip, queue-only-skip, and idle-skip gates are bypassed and the existing humidity / `dry_sf_reason` / drying-presets / mode-1 send path takes over. **Safety: drying temp is capped at `max(40, preset_temp - 5)` for mid-print drying** — Bambu's own release notes for H2D and P2S spell out "Lower drying temperature during printing" / "The drying temperature must not exceed the filament's softening temperature", so a 5 degC offset from the idle preset (floor 40) protects spools inside a hot enclosure during an active print. The early-return guard that short-circuits the evaluator when "only queue mode is on AND nothing scheduled" was also extended to skip the short-circuit when `print_drying_enabled` is on — otherwise busy printers would never be reached. The manual drying button on the AMS card needs no UI change: `routes/printers.py::start_drying` has no Bambuddy-side `is_idle` gate; the "printer busy" rejection comes from firmware `dry_sf_reason=[0]`, which simply won't appear on supported firmware mid-print. The new capability flag is also surfaced on `PrinterStatus.supports_drying_while_printing` so the frontend can light up the AMS card affordances correctly. **Settings.** New `print_drying_enabled: bool = False` in `schemas/settings.py`, added to the boolean allowlist in `routes/settings.py` (`_BOOL_KEYS`), and threaded through the existing dirty-detection / save call in `SettingsPage.tsx`. **i18n.** 2 new keys (`settings.printDryingEnabled`, `settings.printDryingEnabledDescription`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5354 leaves per locale, no English fallback. **Tests.** 7 new cases in `TestSupportsDryingWhilePrinting` cover every supported display name + internal code, below-min firmware, excluded models (`P1*`, `A1`, `A1 MINI`, `X1`, `X1E`), missing firmware, `None` model, case-insensitivity, and the strict unknown-model default (False — unlike `supports_drying` which leniently allows unknowns). 4 new scheduler integration cases in `TestMidPrintDrying` cover: toggle ON + capable hardware fires drying at the 40 degC cap for PLA, PETG caps to 60, toggle OFF still skips busy printers, and toggle ON with too-old firmware / excluded model still skips. Full `pytest -n 30` green (4251/4251 in 49 s). Backend `ruff` clean. Frontend `npm run build` clean. **Scope.** No DB migration. No new permission. The new toggle is opt-in (default OFF) — existing installs see no behaviour change until a user enables it, and the firmware is the ultimate arbiter via `dry_sf_reason` so being too permissive here costs nothing. - -- **Batch / mass edit on the Filament tab (#1795, requested by @RoBoT24-web)** — Bulk operations land on the Inventory page in both built-in and Spoolman modes. Reporter wanted "ten of the same spool, set a pressure advance value, save once" — the existing flow forced ten round-trips through the per-spool editor. **Frontend.** A new checkbox column anchors the leftmost slot of every row in the table view (header checkbox toggles every visible row; group rows expose a single checkbox that selects every member). As soon as one row is selected, a sticky toolbar appears above the list with **Edit / Print labels / Reset usage / Archive (or Restore in the Archived tab) / Delete / Clear selection**. The selection clears automatically on any filter or tab change so the toolbar count can never drift from what's on screen. A new `BulkEditSpoolsModal` is the entry point for the bulk-edit action: a three-state-per-field form (untouched / set-to-value) over the flat spool attributes — material, subtype, brand, color name + RGBA, storage location, slicer filament name + ID, cost / kg, note, label weight, core weight, category, low-stock threshold %. The reporter's pressure-advance use case (K-profile) stays per-spool because K-profiles are scoped per `(printer, extruder, nozzle_diameter)` and bulk-applying a single K-value across heterogeneous printers would create wrong calibration — they're handled in the existing per-spool K-profile editor instead. **Clearing fields in bulk is intentionally NOT supported** (user decision on #1795): bulk-set lets you only WRITE non-empty values; emptying ten notes by mistake is a one-click disaster the dialog doesn't expose. The per-spool editor remains the path for clearing. **Same dropdown controls the per-spool editor uses.** Material, sub-type, brand, category, slicer preset name, and slicer filament are all rendered through a new `SearchableSelect` component matching the per-spool form's pattern (text input + chevron + filtered list of buttons, click-outside + Escape close). No native `