# Changelog All notable changes to Bambuddy will be documented in this file. ## [0.2.5b1] - Unreleased ### Added - **Windows installer build pipeline scaffolded** — Lays down the infrastructure for producing a self-contained Bambuddy Windows installer `.exe` that doesn't require Python, Node, or any other runtime on the target machine. The installer ships an embedded Python 3.13 distribution (matching the Dockerfile's `python:3.13-slim-trixie`), the pre-built React bundle, NSSM (service supervisor), and ffmpeg — everything Bambuddy needs to run end-to-end on a stock Windows 10/11 box. **Architecture:** install target `C:\Program Files\Bambuddy\`, data target `C:\ProgramData\Bambuddy\data\` (preserved on uninstall so reinstalls keep the database + archives), service registered via NSSM running as `LocalSystem` with autostart on boot (LocalSystem is required because the Virtual Printer feature needs to bind 322 / 990 / 8883, all privileged ports on Windows). Browser is the UI — Start Menu shortcut opens `http://localhost:8000`, no Tauri / Electron launcher in v1, which matches how every other Bambuddy platform already works. **Why this shape over a PowerShell `install.ps1`:** the script approach was tried first and abandoned. Each failure across the Windows host fleet is environmental drift (Python version mismatches, execution-policy variants, antivirus heuristics, missing MSVC runtimes, OneDrive-redirected `%APPDATA%`, ARM64 vs x64, PowerShell 5.1 vs 7.x semantics) — a script can't insulate against host state, and every fix you add for one machine breaks two others. The self-contained-bundle approach takes that whole class of failure off the table. **Files:** `installers/windows/build.py` stages everything under `installers/windows/build/staging/`, `installers/windows/bambuddy.iss` is the Inno Setup 6 script, `installers/windows/service/install-service.bat` + `uninstall-service.bat` wrap NSSM. `build.py` hard-fails on non-Windows hosts; cross-build under Wine is an unsupported escape hatch behind `--allow-non-windows`. **CI:** `.github/workflows/windows-installer.yml` runs on tag push (`v*`) and manual dispatch, uses `windows-latest`, downloads Inno Setup via Chocolatey, runs `build.py` + ISCC, uploads the `.exe` as both a workflow artifact and a release asset. **Scope clarification:** this commit lands the build infrastructure, not a verified-working installer. The first real Windows-box smoke test happens after merge by triggering the workflow manually and installing the artifact on a target box; known unknowns are pip-installing `opencv-python-headless` / `curl_cffi` / `asyncpg` / `cryptography` / `bcrypt` against embedded Python (the `_pth` file edits in `build.py` cover the common gotchas but real-runtime imports are where surprises surface), ffmpeg path lookup from a LocalSystem service, and NSSM `AppEnvironmentExtra` line-continuation in cmd.exe. **Signing:** v1 ships unsigned — Windows SmartScreen will warn "Windows protected your PC" on first run, click-through works. SignPath OSS application submitted 2026-06-10 to wire free EV signing into CI once approved (typical 1–3 week approval window). **What's explicitly NOT in v1:** Spoolman bundling (Bambuddy's internal-inventory mode is the v1 default on Windows; users who want Spoolman install it separately), in-place upgrade (uninstall + install cycle works, but in-place upgrade-on-top needs end-to-end verification before we promise it), port-conflict pre-check (deferred to v1.1 — port collisions surface at first service start and the user reads the NSSM stderr log under `C:\ProgramData\Bambuddy\logs\service-stderr.log`). See `installers/windows/README.md` for the full build pipeline. - **Bambu Lab A2L support (#1684)** — Internal model code `N9`, serial prefix `26A19` (5 chars, same shape as H2C's late `31B8B`). Capabilities resolved from BambuStudio's `resources/profiles/BBL/machine/Bambu Lab A2L.json` cross-checked against Bambu's official A2L specs page: linear rail, single FDM extruder + integrated cutter/plotter head (the BambuStudio `use_double_extruder_default_texture: true` flag covers the dual TOOL HEADS, not dual filament extrusion — A2L must NOT route AMS to the deputy slot or firmware rejects with 07FF_8012). Specs page also confirms NO Ethernet (Wi-Fi 2.4 GHz 802.11 b/g/n only), `Low-Rate-Kamera` on the chamber-image protocol (port 6000, NOT RTSP:322), no heated chamber. **Registry updates**: `PRINTER_MODEL_MAP` + `PRINTER_MODEL_ID_MAP` + `LINEAR_RAIL_MODELS` in `utils/printer_models.py`; `MODEL_TO_API_KEY` + `API_KEY_TO_DEV_MODEL` + `API_KEY_TO_WIKI_PATH` in `firmware_check.py` (wiki path follows the established `/en/a2l/manual/a2l-firmware-release-history` pattern; the existing 404 handling in `_fetch_all_versions_from_wiki` makes this safe to ship before Bambu publishes the page); `VIRTUAL_PRINTER_MODELS` + `MODEL_SERIAL_PREFIXES` in `virtual_printer/manager.py` (prefix `26A19A` with the same revision-letter padding as X2D's `20P90A`); `MODEL_PRODUCT_NAMES` in `virtual_printer/mqtt_server.py`; `mapModelCode` + Add-Printer / Edit-Printer model dropdowns in `PrintersPage.tsx` (new "A2 Series" optgroup); `mapModelCode` in `SpoolBuddyAmsPage.tsx`. **Camera and dual-nozzle code paths need no edits**: `supports_rtsp()` correctly falls through to chamber-image for A2L because `N9` is neither in the internal-code RTSP set nor does the display name match the X1/X2/H2/P2 prefix tuple; `is_dual_nozzle_model()` correctly returns False because A2L is not in `DUAL_NOZZLE_MODELS`. The cutter/plotter capability surfaces in MQTT push fields Bambuddy doesn't yet model; ignored for v1, will surface as a follow-up only if a real-world A2L bundle reveals a confusing UI state. **Tests**: 12 new cases in `test_printer_models.py::TestA2LModel` pinning every dimension — rod type, model-id round-trip, both ethernet directions, both camera-port directions, the explicit non-dual-nozzle guard (regression guard for the BambuStudio profile flag misread), set membership in `LINEAR_RAIL_MODELS` and exclusion from `CARBON_ROD_MODELS` / `STEEL_ROD_MODELS`. - **One-shot `device.*` identification probe in MQTT push parser (#1684 enabler)** — Adding support for a new Bambu printer model needs the internal model code the firmware sends in MQTT `device.dev_model_name` (e.g. A1 is `N2S`, H2C is `O1C`, X2D is `N6`). The field arrives on every push but Bambuddy never logged it, so even a debug-enabled support bundle from a new-model user (A2L on #1684 was the case that surfaced this) gave us no way to identify the model — `get_version` was also missing because the printer disconnected right after the request topic subscription, which is a separate firmware quirk. **Fix:** at the top of the existing `device.*` parsing block in `bambu_mqtt.py`, emit one INFO log per client session dumping `dev_model_name` / `dev_product_name` / `dev_id` / `project_name` if any are present; otherwise fall back to `device.keys()` so a future Bambu rename (e.g. `model_name` without the `dev_` prefix) still surfaces. INFO level so the line lands in every support bundle, not just debug-enabled ones; one-shot via a `_device_id_logged` flag matching the existing `_nozzle_fields_logged` pattern at line 2095 — no spam at every push_status. 3 unit tests in `TestDeviceIdentificationProbe` pin the one-shot behaviour, the known-id-field path, and the keys-fallback path. Full `test_bambu_mqtt.py` suite 281 / 281 green; ruff clean. Once this ships, a new-model issue self-resolves from the first bundle — no second round of "please enable debug and reupload" required. - **Archives page banner: reactive install-step-4 nudge for the slicer-side setting** — Companion to the new `external_storage` diagnostic check. The diagnostic catches the printer-side variant of "Store sent files on external storage" via `home_flag` bit 11. The slicer-side variant on older BambuStudio / OrcaSlicer never reaches the printer, so the diagnostic passes even when the option is off in the slicer. The deterministic symptom is the archiver creating a row with `extra_data.no_3mf_available=True` (`main.py:2770`) — that's the signal this banner watches. New backend endpoint `GET /archives/no-3mf-warning` returns `{has_fallback: bool}` — true iff any archive in the last 30 days has the flag set AND isn't soft-deleted. The 30-day window prevents old never-fixed installs from showing the banner forever; the soft-delete filter respects the user clearing the evidence. Frontend banner sits at the top of the Archives page (amber, dismissible) — "Some recent prints couldn't be archived with thumbnails…" + link to install step 4 in the wiki. Dismissal is one-shot via `localStorage` key `archiveNo3MFWarningDismissed` (matches the existing `Layout.tsx` update-banner pattern but persistent across sessions, since "you've been told" should outlive a browser restart). React-Query is `enabled: !dismissed` so the endpoint isn't polled after dismissal. 5 backend integration tests (`TestNo3MFWarning`) cover: recent fallback returns true, no archives returns false, archives without the flag returns false, >30-day-old fallbacks ignored, soft-deleted fallbacks ignored. i18n: 4 new keys (`title`, `body`, `docsLink`, `dismissLabel`) under `archives.no3mfBanner` translated to all 11 locales — no English fallbacks. - **Connection diagnostic now verifies install step 4 ("Store sent files on external storage")** — Many users miss this setting when adding their first printer; without it BambuStudio / OrcaSlicer never leave a `.gcode.3mf` on the printer's SD card, every archived print falls back to no-thumbnail / no-metadata, and the cause is invisible until the user notices the archive is empty. **The trap with detecting this**: on newer firmware (P2S 01.02 / Bambu Studio 2.6+) the toggle moved onto the printer itself and is pushed on MQTT `home_flag` bit 11 (Bambuddy already parses this into `state.store_to_sdcard`). On older versions it's a purely slicer-side preference invisible to the printer. An FTP upload-probe approach was tried first — it always passed regardless of the slicer toggle because the `/cache` directory is always writable from Bambuddy's perspective; the slicer toggle only controls what BambuStudio chooses to do, not what the printer accepts from other clients. Confirmed empirically against an X1C + H2D with the slicer option toggled off (probe still succeeded, `home_flag` bit 11 stayed True). **Fix**: new `external_storage` check reads `state.store_to_sdcard` directly. Pass when the printer reports the bit on, fail when off, skip when no live MQTT state or the field has never been populated (older firmware that doesn't push `home_flag`). Localised fix-text points at install step 4 with both the printer-side and slicer-side variants spelled out; the `skip` text explicitly calls out the older-slicer limitation so users on that path know to verify manually. Slot in the check list sits between `port_ftps` and `mqtt_auth`. 5 new tests (`TestExternalStorageCheck`) cover pass-on-true, fail-on-false, skip-on-disconnect, skip-on-pre-add (no state), skip-on-missing-field. The reactive symptom-side detection — a one-time banner the first time the archiver records `extra_data.no_3mf_available=True` after a slicer-initiated print — is planned as a separate follow-up to cover the slicer-only setting case. Wiki updated on the System page (`features/system-info.md`) and the Troubleshooting page (`reference/troubleshooting.md`). i18n: 4 new keys (title, pass, fail, skip) localised to all 11 locales (de, en, es, fr, it, ja, ko, pt-BR, tr, zh-CN, zh-TW) — no English fallbacks. - **"Open in Slicer" desktop target is now configurable separately from the API sidecar slicer (#1329, reported by @hasmar04)** — Reporter wanted to slice via the Bambu Studio sidecar but open files locally in OrcaSlicer; the existing `preferred_slicer` setting drove both, so picking one forced the other. The slicer-URI flow on Workflow → Slicer literally swapped the BambuStudio handler for the OrcaSlicer one whenever the user switched the API choice. **Fix: new `open_in_slicer` setting** (`'bambu_studio' | 'orcaslicer' | null`) drives only the desktop "Open in Slicer" URI handoff; the in-app SliceModal + sidecar URL routing in `library.py`, `archives.py`, `slicer_presets.py` continue to use `preferred_slicer` exactly as before. Default is `null` — the frontend falls back to `preferred_slicer` so existing installs behave identically until a user changes it (no migration, no churn). **Storage** lives in the existing `app_settings` key/value table; the PUT path serialises a Python None as the literal string `"None"`, and the GET path normalises it back via a new branch in `_build_settings_response` matching the existing `default_printer_id` convention — without that normalization the frontend can't tell "explicit override absent" from "explicit override set to a bogus value". **Frontend**: Settings → Slicer card relabels the existing dropdown's description ("Slicer used for in-app slicing via the API sidecar"), adds a new "Open in Slicer" dropdown below it with three options — "Same as API slicer" (the inherit-from-preferred default), "Bambu Studio", "OrcaSlicer". `ArchivesPage` (5 `openInSlicerWithToken` call sites), `MakerworldPage` (the URI handoff branch when `useSlicerApi=false`), and `ModelViewerModal` (4 `openInSlicer(...)` call sites) all switched from reading `settings?.preferred_slicer` to `settings?.open_in_slicer ?? settings?.preferred_slicer`. MakerworldPage's "Slice in {{slicer}}" button label additionally branches on `useSlicerApi`: when on, the label reflects the API slicer; when off, the desktop slicer — so the button text always matches what the button actually does. The OrcaSlicer "known CLI bugs" warning stays attached to the API dropdown (where it belongs — it's about the sidecar's CLI). **i18n**: 3 new keys in all 11 locales (de/en/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW) — `settings.openInSlicerLabel`, `settings.openInSlicerInherit`, `settings.openInSlicerDescription` — plus an updated `settings.preferredSlicerDescription` everywhere (the old wording "Choose which slicer application to open files with" became wrong once the field stopped driving the desktop handoff). No English fallbacks per the project's hard rule. **Tests**: 3 new in `TestOpenInSlicerOverride` pin the contract — default is null, override persists across GET, explicit reset to null round-trips correctly without leaving the `"None"` string leak. Full backend suite green (5798/5798); frontend ESLint + build clean; vitest on SettingsPage + MakerworldPage 48/48 green; i18n parity 5095 leaves × 11 locales green. - **Queue items + Print modal now show the build plate type, per-plate accurate (#1281, reported by @CMW-ISS)** — Reporter on a multi-printer farm with 40+-plate runs needed to walk to the printer with the right physical plate; the archive card had recently grown a bed-type badge, but the queue and the scheduling modal didn't. They were having to open the source 3MF in the slicer to look up which plate each queued / scheduled job needs. **Backend**: new `extract_bed_type_from_3mf(file_path, plate_id)` helper in `utils/threemf_tools.py`, alongside the existing `extract_filament_usage_from_3mf` shape — reads `Metadata/slice_info.config`, finds the `` with the matching `index`, returns its `curr_bed_type`. When `plate_id` is None it returns the first plate's value (matches the archive-level capture convention). `PrintQueueItemResponse` gains a `bed_type: str | None` field; `_enrich_response` populates it from `archive.bed_type` / `library_file.file_metadata["bed_type"]` as the file-level default, then overrides per-plate via the new helper when `item.plate_id` is set. This matters because `archive.bed_type` is captured at ingest as the FIRST plate's value only (see `services/archive.py:235`) — a 40-plate 3MF mixing PEI + Engineering returns "PEI" for every plate at the archive level, even though the user's plate 17 actually needs Engineering. The per-plate override re-reads the 3MF and returns the truth. **`/archives/{id}/plates`** (and the library-file equivalent) now include `bed_type` in each plate object so the PrintModal's plate selector can render the badge inline. **Frontend**: queue card meta row gains a bed badge after filament weight — uses the existing `getBedTypeInfo(bed_type)` helper from `utils/bedType.ts` (the same one the archive card uses, so all 11 canonical bed labels + icons are covered including the BambuStudio / OrcaSlicer spelling drift). PrintModal's per-plate `PlateSelector` shows the bed badge under each plate's filament line; the modal header carries a bed badge for the selected (or sole) plate, surfaced before the user hits Schedule. `PlateInfo` + `PlateMetadata` types both get an optional `bed_type` field. No new i18n keys needed — `getBedTypeInfo` returns the canonical English plate name as the human label, matching the archive card's existing convention. **Tests**: 8 new unit cases in `test_threemf_tools.py::TestExtractBedTypeFrom3mf` pin the helper (single-plate, multi-plate per-plate, no-plate-id defaults to first, unknown-plate-id → None, plate-without-bed-type → None (no fall-through to another plate's value), missing slice_info, invalid file, whitespace trim). Full backend suite green (3848/3848); frontend build clean; ESLint clean; vitest on touched pages 81/81; i18n parity 5092 leaves × 11 locales green. ### Added - **Print Log page: per-row failure-cause classification (#1687 part 4, reported by @IndividualGhost1905)** — Reporter clarified after part 1 shipped that what he actually wanted for point 2 was failure-cause grouping on the *log* (spaghetti, jam, bed-adhesion, etc.), not the archive tags I'd pointed him at. Archive `tags` describe the model (home decor, toys); the log row needs to describe what went wrong on a single print event. Different surface, different lifetime. **What was already there:** `PrintLogEntry.failure_reason: String(100)` already exists, gets *mirrored* from `archive.failure_reason` when the user edits the archive (see `archives.py:1421` for the mirror that ships with #1444), and the Failure Analysis widget already groups by it. So the storage and the aggregation were both done — the only gaps were (a) the Print Log table couldn't *render* the value because the GET serialiser silently dropped it from `PrintLogEntrySchema`, and (b) **orphan log entries** (failures with no archive — dispatch errors, aborts before archive creation, manual entries) had no edit path at all because the Archive Edit modal can't reach them. **Fix:** four pieces. (1) `print_log.py` GET endpoint now includes `failure_reason` (and `archive_id`, `created_by_id`) in the serialised response — pre-fix it was silently None in every response even when the column was populated. Regression guard added. (2) New `PATCH /print-log/{entry_id}` endpoint accepting `{failure_reason, status}`, gated on `require_ownership_permission(ARCHIVES_UPDATE_ALL, ARCHIVES_UPDATE_OWN)` — same ownership shape as the per-row delete that already shipped. Backend validates `failure_reason` against the same canonical vocabulary the Archive Edit modal uses (11 enumerated keys + empty-string-clears + the `other` catch-all); unknown values return 400 rather than getting stored as raw garbage (the i18n layer renders the value as a key, so an unrecognised one would surface as a literal string in the UI). Status validated against the 5-value `{completed, failed, stopped, cancelled, skipped}` set. Empty-string `failure_reason` stores back as NULL so the column's `nullable=True` intent is preserved end-to-end. (3) `FAILURE_REASON_KEYS` constant moved to an export from `EditArchiveModal.tsx` so the new editor reuses the exact same vocabulary as the archive editor — backend and frontend stay in lockstep. (4) Frontend: pencil icon added beside the existing trash icon on every Print Log row, gated on `archives:update_own`/`archives:update_all`. Click opens a compact two-field modal (status + failure reason dropdowns). Save invalidates both `print-log` and `archives-stats` query keys so the Failure Analysis widget reflects the re-classification on the same response cycle. Failure reason is also rendered as a sub-label under the status badge in the table, mirroring the per-archive `PrintLogTable.tsx` convention so the two views agree. **i18n:** 10 new keys (`editEntryTitle`, `editEntryDescription`, `entryUpdated`, `entryUpdateFailed`, `archives.permission.noEdit`, plus a 5-key `statuses` block) translated across all 11 locales — no English fallbacks per `feedback_translate_dont_fallback`. **Tests:** 8 new backend integration cases — GET surfaces `failure_reason` (regression guard for the silent-drop bug), PATCH sets / clears / rejects unknown failure_reason, PATCH updates status, PATCH rejects unknown status, PATCH returns 404 on missing ID, PATCH works on **orphan entries** (archive_id IS NULL) — the actual reason this endpoint exists. Full backend suite 5843/5843 green; ruff clean. Frontend vitest 2108/2108 green; ESLint + build clean. i18n parity check 5110 leaves × 11 locales green. - **Print Log page: per-row delete (#1687 part 1, reported by @IndividualGhost1905)** — Reporter noted that the existing "Also remove this print from Quick Stats" toggle on archive delete is one-shot: if you tick "keep stats" at delete time, there was no later way to drop the row from /stats; and rows that aren't tied to an archive (errors, aborts, manual entries) had no delete affordance at all. **Fix:** every row in the Archives → Print Log table now has a trash icon next to the filament cell, gated on `archives:delete_own` (own rows) or `archives:delete_all` (any row), matching the archive-delete permission shape. Click → confirm modal → row is gone, and because /archives/stats aggregates over `PrintLogEntry` the filament / time / cost contribution drops out of Quick Stats in the same response cycle. The matching archive (if any) is untouched — the log row is a sibling, not a child. **Backend:** new `DELETE /print-log/{entry_id}` mirrors `delete_archive`'s ownership flow via `require_ownership_permission(ARCHIVES_DELETE_ALL, ARCHIVES_DELETE_OWN)`; owners can drop their own rows, admins can drop any row, missing IDs return 404 rather than 200-silently. **Frontend:** new `deletePrintLogEntry` API helper, per-row mutation that invalidates both `print-log` and `archives-stats` query keys so the totals re-render without a manual refresh. **i18n:** 4 new keys (`deleteEntryTitle`, `deleteEntryConfirm`, `entryDeleted`, `entryDeleteFailed`) translated across all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). **Tests:** 3 backend integration cases — delete drops the row from /stats while keeping the linked archive listed, missing ID returns 404, delete-one does not touch siblings (regression guard against an accidental `delete(PrintLogEntry)` without a `where`). Frontend ArchivesPage / PrintLogModal vitests stay green (31 / 31). i18n parity green (5099 leaves × 11 locales). Issue #1687 also asks for per-row tagging (already covered by `EditArchiveModal`'s tags field) and per-row filament-usage-history edits (deferred — see the issue thread for the reasoning). ### Fixed - **Connection diagnostic no longer flags `external_storage: fail` on A1 / A1 Mini, which physically have no MicroSD slot (#1703, reported by @MartinNYHC)** — Bug report from an A1 user complained that BambuStudio and OrcaSlicer don't have an "external storage" tick box (correct — there's nothing to toggle, the A1 series ships without a SD card slot at all) while the Bambuddy support bundle simultaneously reported `external_storage: fail` in the printer's connection diagnostic. The two together left the user thinking Bambuddy was wrong about a setting their hardware doesn't have. **Root cause:** the `external_storage` check at `services/printer_diagnostic.py:179-189` reads `state.store_to_sdcard`, which is parsed from MQTT `home_flag` bit 11. On A1 and A1 Mini that bit is never set (no hardware slot, no firmware-side toggle, no slicer-side equivalent), so the value pushes as `False` and the check fell through to `fail` instead of `skip`. **Fix:** new `NO_EXTERNAL_STORAGE_MODELS` frozenset in `utils/printer_models.py` enumerating A1, A1 Mini, and their internal codes (N1, N2S, A04, A11, A12), plus a `has_external_storage(model)` helper that returns False for those and True for everything else (unknown models default to True so the check stays active for any future Bambu model that ships *with* a slot — new no-slot models must be added to the set explicitly). The diagnostic now short-circuits to `skip` before reading `store_to_sdcard` when `printer.model` is in the set. **What this does NOT change:** X1 / X1E / P1S / P1P / P2S / H2D / H2D Pro / H2C / H2S / X2D continue to evaluate `store_to_sdcard` exactly as before — the home-flag-bit-off → `fail` path is still the right signal for them. **The companion FTP-upload-timeout symptom in the same bug report (ftp code 28 from BambuStudio when sending to the proxy VP) is a separate Docker-bridge-mode networking constraint, not addressed by this change.** **Tests:** 8 new cases — `TestHasExternalStorage` (5 cases) pins the model list, internal-code aliasing, case/whitespace normalisation, unknown-defaults-true, and null/empty-defaults-true; `TestExternalStorageCheck` gains `test_skips_on_a1_no_external_storage_slot`, `test_skips_on_a1_mini_no_external_storage_slot`, and `test_still_fails_on_x1c_when_toggle_off` (regression guard that the model-aware skip doesn't accidentally silence the genuine signal on slotted models). Full `test_printer_models.py` + `test_printer_diagnostic.py` + archives integration suite green (172/172); ruff clean. - **AMS slot card surfaced the previous spool's preset name after RFID auto-assigned a new spool (reported with H2D-1 / AMS-B3 / PLA-CF showing as "Bambu PLA Silk+")** — Reporter inserted a fresh Bambu PLA-CF spool into AMS-B3, RFID identified it correctly, but the slot card kept showing "Bambu PLA Silk+" (the name from a PLA Silk+ spool that had occupied the slot back in March). Confirmed in the live data: `slot_preset_mappings` row for `(printer_id=1, ams_id=1, tray_id=2)` was `preset_id=GFSA06_09, preset_name='Bambu PLA Silk+', updated_at=2026-03-15` — three months stale. **Root cause:** `slot_preset_mappings.preset_name` is first in the PrintersPage display chain (`PrintersPage.tsx:3624`) and overrides the spool's own `slicer_filament_name` plus the cloud catalog `cloudInfo.name`. The internal-mode manual-assign path (`inventory.apply_spool_to_slot_via_mqtt`) kept this row in sync, but the internal-mode RFID auto-assign path (`spool_tag_matcher.auto_assign_spool`) skipped it entirely. The Spoolman-mode sync path (`main.auto_sync_spoolman_ams_trays`) also skipped it — same bug shape, latent for Spoolman users who'd never manually configured a slot preset, active for those who had. **Fix — three writers in lockstep via one shared helper.** New `backend/app/services/slot_preset_writer.py` exposes a primitive `upsert_slot_preset` plus two convenience wrappers: `upsert_slot_preset_for_spool` for internal `Spool` ORM objects (local-preset numeric ids → `local_{n}`, cloud ids run through `filament_id_to_setting_id`) and `upsert_slot_preset_for_spoolman_spool` for Spoolman dicts (filament.name → preset_name, tray_info_idx → preset_id). All three call sites — the manual-assign block in `inventory.py:396-438`, the RFID auto-assign tail in `spool_tag_matcher.py:auto_assign_spool`, and the per-tray-sync branch in `main.py:auto_sync_spoolman_ams_trays` — now go through the helper. **Self-heal:** existing stale rows from past spool swaps get rewritten the next time a fresh spool is detected on the same slot. No migration script needed. **What this also covers per `feedback_inventory_modes_parity`:** the bug shape exists in both internal and Spoolman modes, so the patch ships fixes for both inventory paths in the same drop — a Spoolman user with a manually-configured slot preset would have seen the same stale-name behavior after every RFID swap until the row was overwritten through Configure Slot. **Tests:** new `test_slot_preset_writer.py` (6 cases) pins the helper contracts — no-op on empty preset_id, upsert idempotency, Spoolman filament.name → preset_name, fallback to material → tray_sub_brands → tray_type, stale-row overwrite from the Spoolman path, skip when tray_info_idx is unknown. New `test_spool_tag_matcher.py` cases (3) pin the internal RFID-auto-assign path — stale-row overwrite (the exact reporter shape: PLA Silk+ → PLA-CF), fresh insert when no row exists, `local_{n}` formatting for numeric local-preset ids. Total touched-area suite 69/69 green; broader related suite (inventory + spoolman + spool_tag + auto_sync) 767/767 green; ruff clean. - **Stats page Failure Analysis widget rendered raw camelCase keys instead of translated reasons (#1687 follow-up, reported by @IndividualGhost1905)** — After #1687 part 4 shipped the per-row Print Log editor, the reporter classified a couple of failed runs and saw "filamentRunout" / "cloggedNozzle" (the literal camelCase keys) appear under Statistics → Failure Analysis → Top Failure Reasons, while the same rows rendered correctly as "Filament runout" / "Clogged nozzle" on the Print Log table. Surfaced an inconsistency I introduced when shipping the new editor: the new Print Log row editor saves the camelCase key (`filamentRunout`) which is what the new backend PATCH validates against, but the older `EditArchiveModal` was still saving the localised label (`"Filament runout"`) as the value — two formats landing in the same `PrintLogEntry.failure_reason` column from two different UI surfaces. The Failure Analysis widget at `frontend/src/pages/StatsPage.tsx:817` and the per-archive run history sub-table at `frontend/src/components/PrintLogTable.tsx:81` both rendered the raw column value without running it through i18n, so the new key-form values surfaced as literal keys. **Fix — three sites in one drop:** (1) `StatsPage.tsx` and (2) `PrintLogTable.tsx` now wrap the value in `t('editArchive.failureReasons.${reason}', { defaultValue: reason })` — same pattern already used at `ArchivesPage.tsx:3874` for the Print Log table. The `defaultValue` fallback keeps legacy translated-text rows rendering as-is, no regression. (3) `EditArchiveModal.tsx` now saves the camelCase key (`