# Changelog All notable changes to Bambuddy will be documented in this file. ## [0.2.5] - Unreleased ### Fixed - **Multi-nozzle prints no longer collapse all filaments onto one nozzle (#1825, reporter @needo37)** — The single-active-extruder shortcut added in #851 (for #827) at `threemf_tools.py:354` runs `before` the per-filament `group_id` mapping, and fires whenever `extruder_nozzle_stats` reports exactly one extruder as having a nozzle installed. On the H2D / H2D Pro / X2D (2-nozzle) and H2C (3+-nozzle tool-changer), this field is data-driven from the slicer profile's enumerated nozzle volume types — when an HT-AMS or High-Flow nozzle's type isn't enumerated in the slice's profile (common with asymmetric extruder setups, e.g. HT-AMS feeding the right nozzle on an H2D), the slicer emits e.g. `['Standard#1', 'Standard#0']` even though the print genuinely uses both extruders. `sum(active_extruders) == 1` triggered → every filament was force-assigned to `physical_extruder_map[active_idx]`, the authoritative per-filament `group_id` was discarded, and the Filament Mapping panel showed both filaments badged **L** with the auto-match hard filter (`print_scheduler.py` `_compute_ams_mapping_for_printer` ~line 1239) blocking the wrong-nozzle tray as "Type not found". Bug is **parser-side and model-agnostic** — triggers purely on 3MF data shape, not on the attached AMS hardware: regular dual-AMS H2D installs typically slice to `['Standard#1', 'Standard#1']` (sum==2) and never enter the buggy branch, which is why this bug was invisible on the most common dual-AMS setup. Physical nozzle routing was **not** affected — the actual extrude path comes from the sliced gcode + the verbatim `nozzle_mapping` from the project_file (#1780), not from this parse — so the bug surfaced as auto-match failure + wrong L/R badge, not wrong-nozzle extrusion. **Fix.** Gate the single-active shortcut on `len(distinct_group_ids) <= 1` from `slice_info.config`. The slice_info parse is hoisted above the shortcut check (and reused by Priority 1) so the gate adds zero extra I/O. When the slice contains ≥2 distinct group_ids, the shortcut skips and the existing `group_id`-based Priority 1 mapping runs. The gate only **narrows** the shortcut path — it can't widen the buggy collapse onto any previously-working slice. The same condition generalizes to H2C and any future N-nozzle printer for free (no nozzle-count branching). **Tests.** Two new cases in `TestExtractNozzleMappingFrom3MF`: `test_single_active_under_report_with_multi_group_falls_through` pins the #1825 regression (`['Standard#1','Standard#0']` + group_ids `{0,1}` → `{1:1, 2:0}` not `{1:1, 2:1}`); `test_single_active_with_single_group_still_uses_shortcut` preserves the #851 behaviour (same stats + only `group_id=0` → shortcut still fires → `{1:1, 2:1}`). Existing `test_single_active_extruder_maps_all_slots` and `test_two_active_extruders_falls_through` stay green. **Suites.** `pytest -n 30 backend/tests/unit/test_scheduler_ams_mapping.py backend/tests/unit/test_scheduler_filament_deficit.py backend/tests/unit/test_scheduler_filament_override.py backend/tests/unit/test_fallback_archive_mqtt_filament.py backend/tests/integration/test_archives_api.py backend/tests/integration/test_library_api.py` 272/272 green. `ruff check backend/` clean. **Scope.** Backend-only, parse layer. No DB migration. No new permission. No frontend change. The L/R-only badge limitation on 3+-nozzle printers (H2C tool-changer) called out in the report is a separate cosmetic follow-up and not part of this fix. - **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": ` 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 `